Files
bmspy/tests/test_ecoflow_river3.py
T
tim f36bb2510c Rename bms_temperature_celcius to bms_temperature_celsius
Correct the spelling in the JBD and EcoFlow snapshots, the diagnostic tool, and the tests. UPS.from_dict maps the old name to the new name, so a client still reads data from an older server during an upgrade.

InfluxDB and Prometheus now receive this measurement under the new name.
2026-10-10 09:03:33 +02:00

299 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""River 3 tests using synthetic protobuf messages, not device captures.
Field numbers/types follow ha-ef-ble's proto/pr705.proto DisplayPropertyUpload.
The packet fixtures model the XOR framing used by upstream River 3 tests.
"""
import asyncio
import importlib.util
import struct
from dataclasses import asdict
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from bmspy.classes import UPS
from bmspy.ecoflow_bt import (
EcoFlowBTDevice, EcoFlowPacket, _EncFrameHelper, _State, _Type7Encryption,
)
from bmspy.ecoflow_river3 import decode_river3_payload
ADDRESS = "02:00:00:00:00:03"
SERIAL = "R651TEST00000001"
def varint(value):
value &= (1 << 64) - 1
data = bytearray()
while value > 127:
data.append((value & 127) | 128)
value >>= 7
data.append(value)
return bytes(data)
def integer(field, value):
return varint(field << 3) + varint(value)
def floating(field, value):
return varint((field << 3) | 5) + struct.pack("<f", value)
def report(payload):
return EcoFlowPacket(2, 0x21, 0xFE, 0x15, payload, version=3)
def device(serial=SERIAL):
return EcoFlowBTDevice(ADDRESS, serial, "test-user")
def display_payload(power=166):
return b"".join([
floating(262, 88.918), floating(3, 244), floating(4, 39),
floating(158, power), integer(259, 29), integer(269, 10),
integer(268, 184), integer(282, 1),
])
def test_decode_numeric_display_fields():
values = decode_river3_payload(2, 0xFE, 0x15, display_payload())
assert values["cms_batt_soc"] == pytest.approx(88.918)
assert values["pow_in_sum_w"] == 244
assert values["pow_out_sum_w"] == 39
assert values["pow_get_bms"] == 166
assert values["bms_max_cell_temp"] == 29
assert values["cms_chg_rem_time"] == 10
assert values["cms_dsg_rem_time"] == 184
assert values["cms_chg_dsg_state"] == 1
assert decode_river3_payload(2, 0xFE, 0x15, b"") == {}
@pytest.mark.parametrize("src,cmd_set,cmd_id", [(3, 0xFE, 0x15), (2, 0x20, 2), (2, 0xFE, 0x16)])
def test_unconfirmed_and_unrelated_messages_are_not_mapped(src, cmd_set, cmd_id):
assert decode_river3_payload(src, cmd_set, cmd_id, display_payload()) is None
@pytest.mark.parametrize("payload", [
b"\x00", b"\x80", b"\x80" * 11, b"\x08" + b"\xff" * 10,
varint(3 << 3 | 5) + b"\x00" * 3,
varint(99 << 3 | 1) + b"\x00" * 7,
varint(99 << 3 | 2) + varint(4) + b"abc",
varint(99 << 3 | 3), varint(1 << 32),
])
def test_malformed_protobuf_is_rejected_atomically(payload):
assert decode_river3_payload(2, 0xFE, 0x15, floating(3, 10) + payload) is None
def test_unknown_fields_and_wrong_wire_types_do_not_become_measurements():
payload = (
integer(3, 99) # Total input must be a float, not a varint.
+ integer(999, 123)
+ varint(998 << 3 | 1) + bytes(8)
+ varint(997 << 3 | 2) + varint(3) + b"abc"
+ floating(4, 10) + floating(4, 20)
)
assert decode_river3_payload(2, 0xFE, 0x15, payload) == {"pow_out_sum_w": 20}
def test_signed_temperatures_and_unsigned_time_validation():
payload = integer(102, -5) + integer(259, -3) + integer(269, 1 << 32)
assert decode_river3_payload(2, 0xFE, 0x15, payload) == {
"cms_batt_temp": -5, "bms_max_cell_temp": -3,
}
@pytest.mark.parametrize("prefix", ["R651", "R653", "R654", "R655"])
def test_river3_snapshot_and_serialization(prefix):
dev = device(prefix + "TEST00000001")
dev._record(report(display_payload()))
snap = dev.snapshot
assert dev._pkt_version == 3
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.88918)
assert snap.bms_temperature_celsius.raw_values == {1: 29}
assert snap.ef_input_power_watts.raw_value == 244
assert snap.ef_output_power_watts.raw_value == 39
assert snap.bms_charge_is_charging.raw_value is True
assert snap.bms_charge_is_discharging.raw_value is False
assert snap.ef_remaining_time_minutes.raw_value == 10
assert snap.ef_charge_remaining_time_minutes.units == "min"
assert snap.ef_charge_state.raw_value == 1
serialized = {name: asdict(value) for name, value in snap.items()}
assert dict(UPS.from_dict(serialized).items()) == dict(snap.items())
@pytest.mark.parametrize("serial", ["R601TEST00000001", "R631TEST00000001", "unknown"])
def test_other_models_do_not_use_river3_decoder(serial):
dev = device(serial)
dev._record(report(display_payload()))
assert dev.snapshot.bms_capacity_charge_ratio is None
assert dev.snapshot.ef_input_power_watts is None
def test_partial_updates_keep_missing_values_and_preserve_explicit_zero():
dev = device()
dev._record(report(floating(262, 0)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0
assert dev.snapshot.ef_input_power_watts is None
dev._record(report(floating(3, 44) + floating(4, 44)))
assert dev.snapshot.bms_charge_is_charging is None # AC bypass is not charging.
dev._record(report(floating(158, 0) + integer(102, -5)))
assert dev.snapshot.bms_charge_is_charging.raw_value is False
assert dev.snapshot.bms_charge_is_discharging.raw_value is False
assert dev.snapshot.ef_remaining_time_minutes is None
assert dev.snapshot.bms_temperature_celsius.raw_values == {1: -5}
assert dev.snapshot.ef_output_power_watts.raw_value == 44
def test_cell_temperature_remains_preferred_across_partial_updates():
dev = device()
dev._record(report(integer(259, 29)))
dev._record(report(integer(102, 26)))
assert dev.snapshot.bms_temperature_celsius.raw_values == {1: 29}
dev._record(report(integer(259, 30)))
assert dev.snapshot.bms_temperature_celsius.raw_values == {1: 30}
def test_remaining_time_tracks_battery_direction_and_missing_estimates():
dev = device()
dev._record(report(display_payload()))
dev._record(report(floating(158, -64)))
assert dev.snapshot.bms_charge_is_charging.raw_value is False
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
assert dev.snapshot.ef_remaining_time_minutes.raw_value == 184
dev._record(report(integer(268, 0xFFFFFFFF)))
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
assert dev.snapshot.ef_remaining_time_minutes is None
dev._record(report(integer(268, 0)))
assert dev.snapshot.ef_remaining_time_minutes.raw_value == 0
dev._record(report(floating(158, 0)))
assert dev.snapshot.ef_remaining_time_minutes is None
def test_no_load_sample_matches_display_hours_without_changing_minute_units():
# Synthetic message using observed measurements, without device identifiers.
# The display showed 89% and 75–76 hours, not 75–76 minutes.
dev = device()
dev._record(report(b"".join([
floating(262, 89), floating(3, 0), floating(4, 0),
floating(158, -1), integer(259, 29), integer(268, 4556),
integer(269, 4559), integer(282, 0),
])))
# A later configuration-only update must not erase these measurements.
dev._record(report(integer(363, 1)))
snap = dev.snapshot
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.89)
assert snap.bms_temperature_celsius.raw_values == {1: 29}
assert snap.ef_input_power_watts.raw_value == 0
assert snap.ef_output_power_watts.raw_value == 0
assert snap.bms_charge_is_charging.raw_value is False
assert snap.bms_charge_is_discharging.raw_value is True
assert snap.ef_charge_state.raw_value == 0
assert snap.ef_charge_remaining_time_minutes.raw_value == 4559
assert snap.ef_discharge_remaining_time_minutes.raw_value == 4556
assert snap.ef_remaining_time_minutes.raw_value == 4556
assert snap.ef_remaining_time_minutes.units == "min"
assert divmod(snap.ef_remaining_time_minutes.raw_value, 60) == (75, 56)
def test_usb_load_sample_uses_battery_power_not_river2_state_codes():
# Observed values encoded into a synthetic report. River 3 reported state 1
# while discharging; River 2 uses state 1 for charging.
dev = device()
dev._record(report(b"".join([
floating(262, 89), floating(3, 0), floating(4, 14),
floating(11, -14.322), floating(158, -16), integer(259, 29),
integer(268, 754), integer(269, 802), integer(281, 1), integer(282, 1),
])))
snap = dev.snapshot
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.89)
assert snap.ef_input_power_watts.raw_value == 0
assert snap.ef_output_power_watts.raw_value == 14
assert snap.bms_charge_is_charging.raw_value is False
assert snap.bms_charge_is_discharging.raw_value is True
assert snap.ef_charge_state.raw_value == 1
assert snap.ef_remaining_time_minutes.raw_value == 754
assert snap.ef_charge_remaining_time_minutes.raw_value == 802
assert divmod(snap.ef_remaining_time_minutes.raw_value, 60) == (12, 34)
def test_river2_time_convention_is_not_assumed_for_river3():
dev = device()
dev._record(report(floating(158, -1) + integer(268, 5999)))
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 5999
@pytest.mark.parametrize("invalid", [float("nan"), float("inf"), float("-inf")])
def test_nonfinite_measurements_are_not_exported(invalid):
dev = device()
dev._record(report(b"".join(floating(field, invalid) for field in (262, 3, 4, 158))))
assert dev.snapshot.bms_capacity_charge_ratio is None
assert dev.snapshot.ef_input_power_watts is None
assert dev.snapshot.ef_output_power_watts is None
assert dev.snapshot.bms_charge_is_charging is None
@pytest.mark.parametrize("soc", [-1, 101])
def test_invalid_soc_and_negative_total_power_are_not_exported(soc):
dev = device()
dev._record(report(floating(262, soc) + floating(3, -1) + floating(4, -2)))
assert dev.snapshot.bms_capacity_charge_ratio is None
assert dev.snapshot.ef_input_power_watts is None
assert dev.snapshot.ef_output_power_watts is None
@pytest.mark.parametrize("version", [3, 0x13])
def test_encrypted_xor_reports_survive_notification_fragmentation(version):
pytest.importorskip("Crypto")
enc = _Type7Encryption(bytes(range(16)), bytes(range(16, 32)))
payload = display_payload(power=-64)
key = 0x75
pkt = EcoFlowPacket(
2, 0x21, 0xFE, 0x15, bytes(b ^ key for b in payload),
version=version, seq=bytes([key, 0, 0, 0]),
)
wire = _EncFrameHelper(enc).encode(pkt)
async def run():
for split in range(1, len(wire)):
dev = device()
dev._state = _State.AUTHENTICATED
dev._assembler = _EncFrameHelper(enc, xor_payload=dev._is_river3)
await dev._on_data(bytearray(wire[:split]))
assert dev.snapshot.ef_input_power_watts is None
await dev._on_data(bytearray(wire[split:]))
assert dev.snapshot.ef_input_power_watts.raw_value == 244
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
assert dev.snapshot._packets["ef_pkt_02_fe_15"].info == payload.hex()
asyncio.run(run())
def test_river2_payload_is_not_xored():
pytest.importorskip("Crypto")
enc = _Type7Encryption(bytes(16), bytes(16))
dev = device("R601TEST00000001")
helper = _EncFrameHelper(enc, xor_payload=dev._is_river3)
pkt = EcoFlowPacket(2, 0x21, 0x20, 2, b"plain", seq=b"\x75\x00\x00\x00", version=2)
assert helper.decode(helper.encode(pkt))[0].payload == b"plain"
def test_collection_waits_for_partial_measurements_not_unconfirmed_runtime(monkeypatch, capsys):
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
spec = importlib.util.spec_from_file_location("ecoflow_diagnostic_river3", path)
script = importlib.util.module_from_spec(spec)
spec.loader.exec_module(script)
dev = device()
fragments = iter([floating(262, 50), display_payload()])
async def receive(_):
dev._record(report(next(fragments)))
sleep = AsyncMock(side_effect=receive)
monkeypatch.setattr(script.asyncio, "sleep", sleep)
expected = script.MODEL_PROFILES["R651"]["expected_fields"]
assert "ef_pkt_02_fe_16" not in expected
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=1))
assert sleep.await_count == 2
assert "complete (1 packet type(s))" in capsys.readouterr().out