Decode River 3 separately and accumulate partial reports against a field-level checklist. Keep watch mode running after completion. Label fields and nested records from the pinned pr705 schema. Preserve unknown values and document unverified meanings and routing. Add synthetic regressions, the River 3 field reference, schema-generation tooling, and upstream attribution.
299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""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_celcius.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_celcius.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_celcius.raw_values == {1: 29}
|
||
dev._record(report(integer(259, 30)))
|
||
assert dev.snapshot.bms_temperature_celcius.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_celcius.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
|