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

242 lines
11 KiB
Python

"""Synthetic River 2 diagnostics. No device captures or private identifiers."""
import asyncio
import importlib.util
import struct
from argparse import Namespace
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket, _State
ADDRESS = "02:00:00:00:00:03"
@pytest.fixture
def script():
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
spec = importlib.util.spec_from_file_location("ecoflow_live_diagnostics", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def device(prefix="R601"):
return EcoFlowBTDevice(ADDRESS, prefix + "TEST00000001", "test-user")
def attach(script, dev, watch=False):
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES[dev.serial_number[:4]], watch)
dev.on_packet(collector.receive)
return collector
def river2_bypass_packets():
"""Synthetic first burst: three core heartbeats, MPPT, and an empty reply."""
pd = bytearray(121)
struct.pack_into("<BHHi", pd, 14, 88, 35, 35, 5218)
ems = bytearray(46)
struct.pack_into("<II", ems, 17, 5999, 5999)
struct.pack_into("<f", ems, 26, 87.5)
bms = bytearray(69)
bms[11], bms[20] = 88, 28
struct.pack_into("<I", bms, 12, 13)
struct.pack_into("<fIII", bms, 53, 87.75, 0, 0, 0)
return [
EcoFlowPacket(2, 0x21, 0x20, 2, bytes(pd), version=2),
EcoFlowPacket(0x35, 0x21, 1, 0x52, b"", version=2),
EcoFlowPacket(3, 0x21, 0x20, 2, bytes(ems), version=2),
EcoFlowPacket(3, 0x21, 0x20, 0x32, bytes(bms), version=2),
EcoFlowPacket(5, 0x21, 0x20, 2, bytes(94), version=2),
]
def test_river2_finishes_on_first_heartbeat_set_with_same_snapshot_format(script, monkeypatch, capsys):
dev = device("R601")
# Include optional INV/MPPT packets in the initial burst as well as the three
# core heartbeats. Normal River 2 output and required packet types stay intact.
for src, cmd_id in [(2, 2), (3, 2), (3, 0x32), (4, 2), (5, 2)]:
dev._record(EcoFlowPacket(src, 0x21, 0x20, cmd_id, bytes(128), version=2))
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
sleep = AsyncMock(side_effect=AssertionError("must not wait"))
monkeypatch.setattr(script.asyncio, "sleep", sleep)
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=120))
script.print_snapshot("River 2", ADDRESS, dev.snapshot, [2, 3, 4, 5])
sleep.assert_not_awaited()
out = capsys.readouterr().out
assert "3/3 required heartbeats received (PD, EMS, BMS)" in out
assert "Optional heartbeat types seen: INV, MPPT. Not seen: none (not required)" in out
for message in ("PD heartbeat", "EMS heartbeat", "BMS heartbeat", "INV heartbeat", "MPPT heartbeat"):
assert message in out
assert "latest fields collected" not in out
@pytest.mark.parametrize("prefix", ["R601", "R603"])
def test_river2_bypass_burst_without_inverter_finishes_immediately(script, monkeypatch, capsys, prefix):
dev = device(prefix)
collector = attach(script, dev)
for packet in river2_bypass_packets():
dev._record(packet)
expected = script.MODEL_PROFILES[prefix]["expected_fields"]
sleep = AsyncMock(side_effect=AssertionError("must not wait for optional INV"))
monkeypatch.setattr(script.asyncio, "sleep", sleep)
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=120, collectors={ADDRESS: collector}))
script.print_snapshot("River 2", ADDRESS, dev.snapshot, [2, 3, 4, 5])
sleep.assert_not_awaited()
out = capsys.readouterr().out
assert "3/3 required heartbeats received (PD, EMS, BMS)" in out
assert "Optional heartbeat types seen: MPPT. Not seen: INV (not required)" in out
assert "Other packet types seen: ef_pkt_35_01_52 (empty payload)" in out
assert "5 packet type(s)" not in out
assert "[INV heartbeat]" not in out
for message in ("PD heartbeat", "EMS heartbeat", "BMS heartbeat", "MPPT heartbeat"):
assert message in out
snap = dev.snapshot
assert snap.ef_packets_received.raw_value == 5
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.8775)
assert snap.bms_temperature_celsius.raw_values == {1: 28}
assert snap.bms_charge_is_charging.raw_value is False
assert snap.bms_charge_is_discharging.raw_value is False
assert snap.ef_input_power_watts.raw_value == snap.ef_output_power_watts.raw_value == 35
assert snap.ef_remaining_time_minutes.raw_value == 5218
assert snap.ef_charge_remaining_time_minutes is None
assert snap.ef_discharge_remaining_time_minutes is None
assert "chg_remain_time: 5999 min" in out
assert "dsg_remain_time: 5999 min" in out
@pytest.mark.parametrize("index,minimum,label", [(0, 23, "PD"), (2, 25, "EMS"), (3, 21, "BMS")])
@pytest.mark.parametrize("empty", [False, True])
@pytest.mark.parametrize("listen", [False, True])
def test_invalid_river2_heartbeat_cannot_complete_collection(
script, capsys, index, minimum, label, empty, listen,
):
dev = device("R601")
collectors = {ADDRESS: attach(script, dev)} if listen else {}
packets = river2_bypass_packets()
valid = packets[index]
packets[index] = EcoFlowPacket(valid.src, valid.dst, valid.cmd_set, valid.cmd_id,
b"" if empty else valid.payload[:minimum - 1], version=2)
for packet in packets:
dev._record(packet)
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0, collectors=collectors))
out = capsys.readouterr().out
assert "timed out, incomplete" in out
assert f"{label} (ef_pkt_" in out
assert "3/3 required heartbeats received" not in out
dev._record(valid)
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0, collectors=collectors))
assert "3/3 required heartbeats received" in capsys.readouterr().out
def test_river2_valid_heartbeat_stays_received_after_a_later_bad_packet(script, capsys):
dev = device("R601")
collector = attach(script, dev)
packets = river2_bypass_packets()
dev._record(packets[0])
dev._record(EcoFlowPacket(2, 0x21, 0x20, 2, b"", version=2))
for packet in packets[1:]:
dev._record(packet)
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0, collectors={ADDRESS: collector}))
assert "3/3 required heartbeats received" in capsys.readouterr().out
def test_river2_accepts_zero_values_and_minimum_valid_core_lengths(script, capsys):
dev = device("R601")
for src, cmd_id, size in [(2, 2, 23), (3, 2, 25), (3, 0x32, 21)]:
dev._record(EcoFlowPacket(src, 0x21, 0x20, cmd_id, bytes(size), version=2))
asyncio.run(script.collect([(ADDRESS, dev, script.MODEL_PROFILES["R601"]["expected_fields"])], max_wait=0))
out = capsys.readouterr().out
assert "3/3 required heartbeats received" in out
assert "Not seen: INV, MPPT (not required)" in out
def test_packet_listeners_receive_duplicates_after_snapshot_updates():
dev = device()
observed = []
dev.on_packet(Mock(side_effect=ValueError("bad listener")))
dev.on_packet(lambda pkt: observed.append((pkt, dev.snapshot.ef_input_power_watts.raw_value)))
updated = Mock()
dev.on_update(updated)
pkt = river2_bypass_packets()[0]
dev._record(pkt)
dev._record(pkt)
assert observed == [(pkt, 35), (pkt, 35)]
assert updated.call_count == 2
def test_river2_watch_keeps_late_unknown_packets_after_completion(script, monkeypatch, capsys):
dev = device()
dev._state = _State.AUTHENTICATED
collector = attach(script, dev, watch=True)
for packet in river2_bypass_packets():
dev._record(packet)
calls = 0
async def receive_later(_):
nonlocal calls
calls += 1
if calls == 1:
dev._record(EcoFlowPacket(0x35, 0x21, 0x35, 0x20, b"synthetic-unknown"))
else:
raise asyncio.CancelledError()
monkeypatch.setattr(script.asyncio, "sleep", receive_later)
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
with pytest.raises(asyncio.CancelledError):
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0,
collectors={ADDRESS: collector}, watch=True))
out = capsys.readouterr().out
assert calls == 2
assert "3/3 required heartbeats received" in out
assert "continuing watch for optional or unknown information" in out
assert "raw_payload: " + b"synthetic-unknown".hex() in out
assert "timed out" not in out
@pytest.mark.parametrize("failure", [None, "cancel", "output", "disconnect"])
def test_river2_run_listens_before_connect_and_always_disconnects(script, monkeypatch, capsys, failure):
dev = device()
async def connect(**_):
dev._state = _State.AUTHENTICATED
packets = river2_bypass_packets()
dev._record(packets[0])
# Valid receipt must survive a later bad packet even before collect starts.
dev._record(EcoFlowPacket(2, 0x21, 0x20, 2, b"", version=2))
for packet in packets[1:]:
dev._record(packet)
if failure == "disconnect":
dev._state = _State.DISCONNECTED
manager = Mock(add_device=Mock(return_value=dev), connect_all=AsyncMock(side_effect=connect),
disconnect_all=AsyncMock())
monkeypatch.setattr(script, "EcoFlowBTManager", Mock(return_value=manager))
if failure == "cancel":
monkeypatch.setattr(script.asyncio, "sleep", AsyncMock(side_effect=asyncio.CancelledError()))
if failure == "output":
monkeypatch.setattr(script.PacketCollector, "_receive", Mock(side_effect=OSError("output failed")))
args = Namespace(scan=False, device=ADDRESS, serial_number=dev.serial_number,
debug=0, timeout=0, watch=bool(failure))
if failure:
error = {"cancel": asyncio.CancelledError, "output": OSError, "disconnect": ConnectionError}[failure]
with pytest.raises(error):
asyncio.run(script.run("test-user", args))
else:
asyncio.run(script.run("test-user", args))
assert "3/3 required heartbeats received" in capsys.readouterr().out
manager.disconnect_all.assert_awaited_once()
@pytest.mark.parametrize("value", ["", "not-hex", None, 123])
def test_river2_snapshot_validation_rejects_invalid_hex(script, value):
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R601"])
snapshot = Mock()
snapshot.items.return_value = [(key, Mock(info=value)) for key in script.RIVER2_REQUIRED_HEARTBEATS]
collector.observe_snapshot(snapshot)
assert len(collector.missing()) == 3