"""River 2 protocol tests. Payload fixtures are synthetic, not hardware captures. Offsets follow ha-ef-ble's DirectPdHeartbeatPack, DirectEmsDeltaHeartbeatPack, and DirectBmsMDeltaHeartbeatPack. Fixtures do not use bmspy's field descriptors so an accidental layout change cannot silently change the test data too. """ import asyncio import importlib.util import struct import sys from argparse import Namespace from dataclasses import asdict from itertools import permutations from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest from bmspy.classes import UPS from bmspy.ecoflow_bt import ( EcoFlowBTDevice, EcoFlowBTManager, EcoFlowPacket, _EncFrameHelper, _RawHeaderHelper, _State, _Type1Encryption, _Type7Encryption, _crc8, _crc16, _enc_packet_to_bytes, _parse_enc_frame, collect_data, initialise_device, ) from bmspy.ecoflow_proto import decode_payload from bmspy.ecoflow_river2 import decode_river2_payload # Synthetic device identifiers, not values from a real device. ADDRESS = "02:00:00:00:00:01" SERIAL = "R601TEST00000001" def device(serial=SERIAL, **kwargs): return EcoFlowBTDevice(ADDRESS, serial, "test-user", **kwargs) def pd_payload(soc=73, watts_out=42, watts_in=160, remaining=87): data = bytearray(23) struct.pack_into("= 0.05 @pytest.mark.parametrize("disconnected", [False, True]) def test_wait_stops_at_the_limit_or_a_lost_connection(monkeypatch, disconnected): monkeypatch.setattr("bmspy.ecoflow_bt._COMPLETION_POLL_SECONDS", 0.01) async def run(): dev = device() dev._record(heartbeat(2, pd_payload())) if disconnected: dev._on_ble_disconnect(None) loop = asyncio.get_running_loop() start = loop.time() missing = await dev.wait_for_measurements(0, 30 if disconnected else 0.05) return missing, loop.time() - start missing, elapsed = asyncio.run(run()) assert missing == {"EMS heartbeat", "BMS heartbeat"} assert elapsed < 5 def test_wait_uses_only_the_minimum_for_a_model_without_a_decoder(): async def run(): return await device(serial="XXXXTEST00000001").wait_for_measurements(0, 30) assert asyncio.run(run()) == set() def test_collection_reports_missing_reports_and_logs_changes(monkeypatch, capsys): from bmspy import ecoflow_bt monkeypatch.setattr(ecoflow_bt, "_last_missing", {}) results = [{"EMS heartbeat", "BMS heartbeat"}, {"EMS heartbeat", "BMS heartbeat"}, set()] snapshots = [] def fake_device(**kwargs): snapshots.append(ecoflow_bt.EcoFlowSnapshot()) return Mock( connect=AsyncMock(return_value=True), wait_authenticated=AsyncMock(return_value=True), wait_for_measurements=AsyncMock(return_value=results[len(snapshots) - 1]), disconnect=AsyncMock(), _pkt_count=1, snapshot=snapshots[-1], ) monkeypatch.setattr(ecoflow_bt, "EcoFlowBTDevice", fake_device) config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=7, listen_timeout=20) logs = [] for _ in results: assert collect_data(config) is snapshots[-1] logs.append(capsys.readouterr().out) assert [snap.ef_missing_measurements.info for snap in snapshots] == [ "BMS heartbeat, EMS heartbeat", "BMS heartbeat, EMS heartbeat", "", ] # One line for each change, not one for each collection. assert "collection incomplete; missing BMS heartbeat, EMS heartbeat" in logs[0] assert logs[1] == "" assert "collection complete again" in logs[2] @pytest.fixture def diagnostic_script(): path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py" spec = importlib.util.spec_from_file_location("ecoflow_diagnostic", path) script = importlib.util.module_from_spec(spec) spec.loader.exec_module(script) return script def test_diagnostic_script_decodes_exact_source(diagnostic_script): lines = diagnostic_script._decode_snapshot_packet("ef_pkt_03_20_02", ems_payload().hex(), [2, 3]) assert "EMS heartbeat" in lines[0] assert any("dsg_remain_time: 240 min" in line for line in lines) def test_diagnostic_printer_preserves_empty_packet(diagnostic_script, capsys): dev = device() dev._record(EcoFlowPacket(0x35, 0x21, 0x01, 0x52, b"", version=2)) dev._record(heartbeat(2, pd_payload())) diagnostic_script.print_snapshot("River 2", ADDRESS, dev.snapshot, [2, 3]) output = capsys.readouterr().out assert "ef_pkt_35_01_52:" in output assert "(empty payload)" in output assert "BMSInfoField(" not in output assert "PD heartbeat" in output assert "watts_in_sum: 160 W" in output @pytest.mark.parametrize("payload", ["BMSInfoField(info='')", "zz", "a", None]) def test_diagnostic_invalid_hex_is_reported(diagnostic_script, payload): lines = diagnostic_script._decode_snapshot_packet("ef_pkt_35_01_52", payload, [2, 3]) assert "invalid hex payload" in lines[0] @pytest.mark.parametrize("failure_stage", [None, "connect", "collect", "print", "cancel"]) def test_diagnostic_run_always_disconnects(diagnostic_script, monkeypatch, failure_stage): manager = Mock( connect_all=AsyncMock(), disconnect_all=AsyncMock(), add_device=Mock(return_value=device()), ) collect = AsyncMock() printer = Mock() monkeypatch.setattr(diagnostic_script, "EcoFlowBTManager", Mock(return_value=manager)) monkeypatch.setattr(diagnostic_script, "collect", collect) monkeypatch.setattr(diagnostic_script, "print_snapshot", printer) args = Namespace(scan=False, device=ADDRESS, serial_number=SERIAL, debug=0, timeout=0) if failure_stage is not None: target = { "connect": manager.connect_all, "collect": collect, "print": printer, "cancel": collect, }[failure_stage] error_type = asyncio.CancelledError if failure_stage == "cancel" else RuntimeError target.side_effect = error_type("test failure") with pytest.raises(error_type): asyncio.run(diagnostic_script.run("test-user", args)) else: asyncio.run(diagnostic_script.run("test-user", args)) printer.assert_called_once() manager.disconnect_all.assert_awaited_once() manager.add_device.assert_called_once_with( address=ADDRESS, serial_number=SERIAL, encrypt_type=7, ) @pytest.mark.parametrize("select_device,serial_override", [ (False, None), (True, None), (True, SERIAL), ]) @pytest.mark.parametrize("expected_type", [1, 7]) def test_diagnostic_uses_discovery_instead_of_saved_identifiers( diagnostic_script, monkeypatch, select_device, serial_override, expected_type, ): detected = SimpleNamespace(address=ADDRESS, serial_number=SERIAL, encrypt_type=expected_type, rssi=-50) unsupported = SimpleNamespace(address="02:00:00:00:00:02", serial_number="UNKNOWN", encrypt_type=7, rssi=-50) scanner = AsyncMock(return_value=[detected, unsupported]) manager = Mock( connect_all=AsyncMock(), disconnect_all=AsyncMock(), add_device=Mock(return_value=device()), ) monkeypatch.setattr(diagnostic_script, "scan_for_devices", scanner) monkeypatch.setattr(diagnostic_script, "EcoFlowBTManager", Mock(return_value=manager)) monkeypatch.setattr(diagnostic_script, "collect", AsyncMock()) monkeypatch.setattr(diagnostic_script, "print_snapshot", Mock()) args = Namespace(scan=True, device=ADDRESS if select_device else None, serial_number=serial_override, debug=0, timeout=0) asyncio.run(diagnostic_script.run("test-user", args)) manager.add_device.assert_called_once_with( address=ADDRESS, serial_number=SERIAL, encrypt_type=expected_type, ) manager.disconnect_all.assert_awaited_once() @pytest.mark.parametrize("address,serial,scan,found,error", [ (None, None, False, [], "Supply --device"), (None, SERIAL, False, [], "--serial-number requires --device"), (ADDRESS, None, False, [], "Supply --serial-number"), (ADDRESS, None, True, [], "Supply --serial-number"), (ADDRESS, "UNKNOWN", False, [], "Unsupported serial prefix"), (None, None, True, [], "No EcoFlow devices found"), ]) def test_diagnostic_missing_device_details_fail_before_connecting( diagnostic_script, monkeypatch, address, serial, scan, found, error, ): factory = Mock() monkeypatch.setattr(diagnostic_script, "EcoFlowBTManager", factory) monkeypatch.setattr(diagnostic_script, "scan_for_devices", AsyncMock(return_value=found)) args = Namespace(scan=scan, device=address, serial_number=serial, debug=0, timeout=0) with pytest.raises(SystemExit, match=error): asyncio.run(diagnostic_script.run("test-user", args)) factory.assert_not_called() def test_diagnostic_help_has_no_capture_key_or_encryption_options(diagnostic_script, monkeypatch, capsys): monkeypatch.setattr(sys, "argv", ["ecoflow-bt.py", "--help"]) with pytest.raises(SystemExit) as exc: diagnostic_script.main() assert exc.value.code == 0 text = capsys.readouterr().out assert "--keys-out" not in text assert "--encrypt-type" not in text assert "--scan" in text and "--watch" in text @pytest.mark.parametrize("obsolete", [["--keys-out", "unused.json"], ["--encrypt-type", "7"]]) def test_diagnostic_rejects_obsolete_flags_before_connecting(diagnostic_script, monkeypatch, capsys, obsolete): run = Mock() monkeypatch.setattr(diagnostic_script, "run", run) monkeypatch.setattr(sys, "argv", ["ecoflow-bt.py", "--user-id", "test-user", "--scan", *obsolete]) with pytest.raises(SystemExit) as exc: diagnostic_script.main() assert exc.value.code == 2 assert "unrecognized arguments" in capsys.readouterr().err run.assert_not_called() @pytest.mark.parametrize("serial", [SERIAL, "R651TEST00000001"]) def test_type7_live_decryption_needs_no_public_key_export(serial, monkeypatch): async def scenario(): dev = device(serial) initial = _Type7Encryption(bytes(range(16)), bytes(range(16, 32))) dev._enc = initial session_key = bytes(range(32, 48)) seed_data = bytes(range(18)) derive = Mock(return_value=session_key) monkeypatch.setattr(dev, "_derive_session_key", derive) monkeypatch.setattr(dev, "_do_auth_status", AsyncMock()) try: await dev._process_session_key(initial.encrypt(seed_data)) derive.assert_called_once_with(seed_data[16:18], seed_data[:16]) assert not hasattr(dev, "session_key") assert not hasattr(dev, "session_iv") # The active transport still owns and uses the negotiated key. peer = _EncFrameHelper(_Type7Encryption(session_key, initial.iv)) packet = EcoFlowPacket(2, 0x21, 0x20, 2, b"synthetic-telemetry", version=dev._pkt_version) decoded = dev._assembler.decode(peer.encode(packet)) assert len(decoded) == 1 assert decoded[0].payload == packet.payload finally: await dev.disconnect() asyncio.run(scenario())