Files
bmspy/tests/test_ecoflow_bt.py
T
tim 0c179d29e5 Disconnect from Bluetooth devices before the server stops
The polling threads are daemon threads, so a stop ended the server in the middle of a Bluetooth read. The read got no disconnect, and the adapter kept an open connection or a scan in progress.

The server now sets the stop event at exit, closes its socket, and waits at most 20 seconds for the polling threads. A Bluetooth read that sees the stop event cancels its current step, disconnects, and returns. A read that waits for the adapter does not connect after a stop.
2026-10-10 11:47:16 +02:00

886 lines
36 KiB
Python

"""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
import threading
import time
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("<BHHi", data, 14, soc, watts_out, watts_in, remaining)
return bytes(data)
def ems_payload(charging=87, discharging=240, soc=None):
data = bytearray(25 if soc is None else 30)
data[0] = 1
struct.pack_into("<II", data, 17, charging, discharging)
if soc is not None:
struct.pack_into("<f", data, 26, soc)
return bytes(data)
def bms_payload(soc=73.25, temperature=29, watts_in=110, watts_out=0):
data = bytearray(69)
data[11] = 73
data[20] = temperature
struct.pack_into("<fIII", data, 53, soc, watts_in, watts_out, 87)
return bytes(data)
def heartbeat(src, payload, cmd_id=0x02):
return EcoFlowPacket(src, 0x21, 0x20, cmd_id, payload, version=2)
@pytest.mark.parametrize("serial,version", [
(SERIAL, 2), ("R603XXXXXXXXXXXX", 2), ("R651TEST00000001", 3),
("unknown", 3),
])
def test_model_selects_packet_version_not_encryption(serial, version):
dev = device(serial)
assert dev._pkt_version == version
assert dev._enc_type == 7
def test_explicit_packet_version_and_manager_defaults():
assert device(packet_version=3)._pkt_version == 3
manager = EcoFlowBTManager("test-user")
assert manager.add_device(ADDRESS, SERIAL)._pkt_version == 2
cfg = initialise_device(ADDRESS, SERIAL, "test-user")
assert EcoFlowBTDevice(cfg.address, cfg.serial_number, cfg.user_id,
packet_version=cfg.packet_version)._pkt_version == 2
@pytest.mark.parametrize("version,overhead", [(2, 18), (3, 20)])
def test_packet_header_layout(version, overhead):
pkt = EcoFlowPacket(0x21, 0x35, 0x35, 0x86, b"test", version=version)
raw = pkt.to_bytes()
assert len(raw) == overhead + 4
assert raw[:4] == bytes([0xAA, version, 4, 0])
assert raw[12:14] == b"\x21\x35"
assert raw[overhead - 4:overhead - 2] == b"\x35\x86"
if version == 3:
assert raw[14:16] == b"\x01\x01"
decoded = EcoFlowPacket.from_bytes(raw)
assert decoded is not None
assert decoded.payload == b"test"
assert decoded.version == version
assert decoded.cmd_set == 0x35
assert decoded.cmd_id == 0x86
@pytest.mark.parametrize("raw", [b"", b"\xaa", b"\xaa\x02", b"\xaa\x02\x00\x00"])
def test_short_packet_is_rejected(raw):
assert EcoFlowPacket.from_bytes(raw) is None
def test_bad_crc_and_inconsistent_length_are_rejected():
raw = bytearray(heartbeat(2, pd_payload()).to_bytes())
raw[-1] ^= 1
assert EcoFlowPacket.from_bytes(raw) is None
raw[2] += 1
raw[4] = _crc8(raw[:4])
raw[-2:] = struct.pack("<H", _crc16(raw[:-2]))
assert EcoFlowPacket.from_bytes(raw) is None
def test_outer_frames_fragmentation_coalescing_and_embedded_prefix():
# A marker inside an incomplete frame must not replace the real header.
payload = b"abcd\x5a\x5a" + bytes(range(50))
wire = _enc_packet_to_bytes(1, payload)
second = _enc_packet_to_bytes(1, b"next")
for split in range(len(wire) + 1):
buf = bytearray()
first, buf = _parse_enc_frame(wire[:split], buf)
rest, buf = _parse_enc_frame(wire[split:] + second, buf)
assert first + rest == [payload, b"next"]
assert not buf
def test_outer_frame_discards_noise_and_bad_lengths():
buf = bytearray()
assert _parse_enc_frame(b"noise" * 100, buf)[0] == []
assert not buf
wire = _enc_packet_to_bytes(1, b"ok")
for bad_length in [0, 1, 10001]:
bad = b"\x5a\x5a\x10\x01" + struct.pack("<H", bad_length) + b"00"
assert _parse_enc_frame(bad + wire, bytearray())[0] == [b"ok"]
packets, buf = _parse_enc_frame(b"noise" + wire[:1], bytearray())
assert packets == []
assert _parse_enc_frame(wire[1:], buf)[0] == [b"ok"]
corrupt = wire[:-1] + bytes([wire[-1] ^ 1])
assert _parse_enc_frame(corrupt + wire, bytearray())[0] == [b"ok"]
@pytest.fixture
def enc_helper():
pytest.importorskip("Crypto")
return _EncFrameHelper(_Type7Encryption(bytes(range(16)), bytes(range(16, 32))))
@pytest.mark.parametrize("version", [2, 3])
def test_encrypted_frames_round_trip(enc_helper, version):
pkt = EcoFlowPacket(2, 0x21, 0x20, 2, pd_payload(), version=version)
wire = enc_helper.encode(pkt)
for split in range(1, len(wire)):
assert enc_helper.decode(wire[:split]) == []
decoded = enc_helper.decode(wire[split:])
assert len(decoded) == 1
assert decoded[0].payload == pkt.payload
assert decoded[0].version == version
def test_type1_version2_round_trip():
pytest.importorskip("Crypto")
helper = _RawHeaderHelper(_Type1Encryption(bytes(16), bytes(16)))
pkt = heartbeat(2, pd_payload())
wire = helper.encode(pkt)
assert helper.decode(wire[:7]) == []
assert helper.decode(wire[7:])[0].payload == pkt.payload
def test_numeric_decoder_uses_source_and_handles_short_firmware_payloads():
pd = decode_river2_payload(2, 0x20, 2, pd_payload())
ems = decode_river2_payload(3, 0x20, 2, ems_payload())
assert pd["watts_in_sum"] == 160
assert pd["watts_out_sum"] == 42
assert pd["remain_time"] == 87
assert ems["chg_remain_time"] == 87
assert ems["dsg_remain_time"] == 240
assert decode_river2_payload(2, 0x20, 2, pd_payload() + b"\x00") == {
**pd, "beep_mode": 0,
}
assert decode_river2_payload(2, 0xFE, 0x15, pd_payload()) is None
assert decode_river2_payload(9, 0x20, 2, pd_payload()) is None
@pytest.mark.parametrize("src,cmd_id,payload", [
(2, 2, pd_payload()), (3, 2, ems_payload()), (3, 0x32, bms_payload()[:21]),
])
def test_truncated_core_heartbeat_is_rejected(src, cmd_id, payload):
for length in range(len(payload)):
assert decode_river2_payload(src, 0x20, cmd_id, payload[:length]) is None
def test_snapshot_contains_power_temperature_and_times():
dev = device()
updates = []
dev.on_update(lambda: updates.append(dev.snapshot.ef_input_power_watts.raw_value))
dev._record(heartbeat(2, pd_payload()))
dev._record(heartbeat(3, ems_payload()))
dev._record(heartbeat(3, bms_payload(), 0x32))
snap = dev.snapshot
assert updates == [160, 160, 160]
assert snap.ef_input_power_watts.raw_value == 160
assert snap.ef_input_power_watts.units == "W"
assert snap.ef_output_power_watts.raw_value == 42
assert snap.bms_capacity_charge_ratio.raw_value == 0.7325
assert snap.bms_temperature_celsius.raw_values == {1: 29}
assert snap.bms_temperature_celsius.units == "°C"
assert snap.ef_remaining_time_minutes.raw_value == 87
assert snap.ef_charge_remaining_time_minutes.raw_value == 87
assert snap.ef_discharge_remaining_time_minutes.raw_value == 240
assert snap.ef_charge_remaining_time_minutes.units == "min"
assert snap.ef_charge_state.raw_value == 1
assert snap.bms_charge_is_charging.raw_value is True
assert snap.bms_charge_is_discharging.raw_value is False
assert snap.ef_packets_received.raw_value == 3
assert set(snap._packets) == {
"ef_pkt_02_20_02", "ef_pkt_03_20_02", "ef_pkt_03_20_32",
}
# All metrics and source-specific packets survive the server/client format.
serialized = {name: asdict(value) for name, value in snap.items()}
assert dict(UPS.from_dict(serialized).items()) == dict(snap.items())
def test_zero_power_bypass_and_discharge():
dev = device()
dev._record(heartbeat(2, pd_payload(watts_in=42, watts_out=42)))
assert dev.snapshot.bms_charge_is_charging is None
dev._record(heartbeat(3, bms_payload(watts_in=0), 0x32))
assert dev.snapshot.bms_charge_is_charging.raw_value is False
dev._record(heartbeat(2, pd_payload(watts_in=0, watts_out=42)))
dev._record(heartbeat(3, bms_payload(watts_in=0, watts_out=50), 0x32))
assert dev.snapshot.ef_input_power_watts.raw_value == 0
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
def test_unavailable_times_clear_previous_values_and_zero_is_valid():
dev = device()
dev._record(heartbeat(2, pd_payload()))
dev._record(heartbeat(3, ems_payload()))
dev._record(heartbeat(2, pd_payload(remaining=-1)))
dev._record(heartbeat(3, ems_payload(charging=0xFFFFFFFF, discharging=0)))
assert dev.snapshot.ef_remaining_time_minutes is None
assert dev.snapshot.ef_charge_remaining_time_minutes is None
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 0
def test_short_bms_has_temperature_without_inventing_charge_status():
dev = device()
dev._record(heartbeat(3, bms_payload()[:21], 0x32))
assert dev.snapshot.bms_temperature_celsius.raw_values == {1: 29}
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.73
assert dev.snapshot.bms_charge_is_charging is None
@pytest.mark.parametrize("soc", [float("nan"), float("inf"), -1, 101])
def test_invalid_soc_is_not_exported(soc):
dev = device()
dev._record(heartbeat(3, bms_payload(soc=soc), 0x32))
assert dev.snapshot.bms_capacity_charge_ratio is None
@pytest.mark.parametrize("order", list(permutations(["pd", "ems", "bms"])))
def test_precise_soc_is_independent_of_packet_order(order):
dev = device()
packets = {
"pd": heartbeat(2, pd_payload(soc=89)),
"ems": heartbeat(3, ems_payload(soc=88.91)),
"bms": heartbeat(3, bms_payload(soc=88.918), 0x32),
}
for source in order:
dev._record(packets[source])
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == pytest.approx(0.88918)
# Source preference must not freeze the first precise reading.
dev._record(heartbeat(3, bms_payload(soc=89.25), 0x32))
dev._record(heartbeat(2, pd_payload(soc=89)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.8925
def test_ems_float_is_a_fallback_when_bms_float_is_missing():
dev = device()
dev._record(heartbeat(3, ems_payload(soc=88.5)))
dev._record(heartbeat(3, bms_payload()[:21], 0x32))
dev._record(heartbeat(2, pd_payload(soc=89)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.885
dev._record(heartbeat(3, ems_payload(soc=89.5)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.895
@pytest.mark.parametrize("soc", [0.0, 100.0])
def test_precise_soc_accepts_zero_and_full_charge(soc):
dev = device()
dev._record(heartbeat(3, bms_payload(soc=soc), 0x32))
dev._record(heartbeat(2, pd_payload(soc=73)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == soc / 100
def test_invalid_float_does_not_block_valid_soc_fallback():
dev = device()
dev._record(heartbeat(3, bms_payload(soc=float("nan")), 0x32))
dev._record(heartbeat(3, ems_payload(soc=float("inf"))))
dev._record(heartbeat(2, pd_payload(soc=89)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.89
dev._record(heartbeat(3, bms_payload(soc=88.5), 0x32))
dev._record(heartbeat(3, bms_payload(soc=float("nan")), 0x32))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.885
def test_soc_source_preference_resets_on_new_connection(monkeypatch):
client = Mock(
connect=AsyncMock(), disconnect=AsyncMock(), is_connected=True,
start_notify=AsyncMock(), stop_notify=AsyncMock(),
)
monkeypatch.setitem(sys.modules, "bleak", SimpleNamespace(
BleakClient=Mock(return_value=client),
))
async def run():
dev = device()
dev._record(heartbeat(3, bms_payload(soc=88.5), 0x32))
dev._auth_type7_init = AsyncMock()
try:
assert await dev.connect()
dev._record(heartbeat(2, pd_payload(soc=90)))
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.9
finally:
await dev.disconnect()
asyncio.run(run())
@pytest.mark.parametrize("current", [-5, 0, 12])
def test_battery_pack_voltage_and_signed_current_units(current):
bms = bytearray(bms_payload())
ems = bytearray(ems_payload())
struct.pack_into("<II", bms, 12, 13, current & 0xFFFFFFFF)
struct.pack_into("<II", ems, 3, 13, current & 0xFFFFFFFF)
struct.pack_into("<H", bms, 39, 3460) # Cell voltage remains mV.
struct.pack_into("<I", bms, 49, 20000) # Charge-current limit remains mA.
for cmd_id, payload, voltage_name, current_name in (
(0x32, bytes(bms), "vol", "amp"),
(0x02, bytes(ems), "chg_vol", "chg_amp"),
):
values = decode_river2_payload(3, 0x20, cmd_id, payload)
assert values[voltage_name] == 13
assert values[current_name] == current
lines = decode_payload(3, 0x20, cmd_id, payload)
assert f" {voltage_name}: 13 V" in lines
assert f" {current_name}: {current} A" in lines
lines = decode_payload(3, 0x20, 0x32, bytes(bms))
assert " max_cell_vol: 3460 mV" in lines
assert " tag_chg_amp: 20000 mA" in lines
def test_mppt_voltage_setting_is_volts_not_millivolts():
data = bytearray(75)
struct.pack_into("<I", data, 8, 1122)
struct.pack_into("<I", data, 61, 8000)
struct.pack_into("<IB", data, 68, 230, 50)
lines = decode_payload(5, 0x20, 2, bytes(data))
assert " cfg_ac_out_voltage: 230 V" in lines
assert " cfg_ac_out_freq: 50 Hz" in lines
assert " in_vol: 1122 mV" in lines
assert " cfg_dc_chg_current: 8000 mA" in lines
def test_unverified_inv_configuration_is_not_labeled_as_measured_frequency():
data = bytearray(52)
data[22] = 50
data[51] = 2
lines = decode_payload(4, 0x20, 2, bytes(data))
assert " inv_out_freq: 50 Hz" in lines
assert " cfg_ac_out_freq: 2" in lines
def test_capped_times_are_omitted_but_retained_in_diagnostics():
dev = device()
dev._record(heartbeat(2, pd_payload()))
dev._record(heartbeat(3, ems_payload()))
dev._record(heartbeat(2, pd_payload(remaining=5999)))
dev._record(heartbeat(3, ems_payload(charging=5999, discharging=5999)))
names = dict(dev.snapshot.items())
assert "ef_remaining_time_minutes" not in names
assert "ef_charge_remaining_time_minutes" not in names
assert "ef_discharge_remaining_time_minutes" not in names
raw = bytes.fromhex(names["ef_pkt_03_20_02"].info)
assert " chg_remain_time: 5999 min" in decode_payload(3, 0x20, 2, raw)
def test_switching_direction_clears_inactive_time_estimate():
dev = device()
dev._record(heartbeat(3, ems_payload(charging=69, discharging=5999)))
assert dev.snapshot.ef_charge_remaining_time_minutes.raw_value == 69
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
dev._record(heartbeat(3, ems_payload(charging=5999, discharging=184)))
assert dev.snapshot.ef_charge_remaining_time_minutes is None
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 184
@pytest.mark.parametrize("minutes", [0, 10, 192, 5998, 6000])
def test_non_sentinel_times_are_not_adjusted(minutes):
dev = device()
dev._record(heartbeat(2, pd_payload(remaining=minutes)))
dev._record(heartbeat(3, ems_payload(charging=minutes, discharging=minutes)))
assert dev.snapshot.ef_remaining_time_minutes.raw_value == minutes
assert dev.snapshot.ef_charge_remaining_time_minutes.raw_value == minutes
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == minutes
def test_river3_does_not_use_river2_struct_decoder():
dev = device("R651TEST00000001")
dev._record(heartbeat(2, pd_payload()))
assert dev.snapshot.ef_input_power_watts is None
assert "ef_pkt_02_20_02" in dev.snapshot._packets
def test_fragmented_auth_probe_then_auth_and_telemetry(enc_helper):
async def run():
dev = device()
dev._assembler = enc_helper
dev._state = _State.AUTH_STATUS
dev._write_packet = AsyncMock()
probe = EcoFlowPacket(0x35, 0x21, 0x35, 0x89, b"\x00", version=2)
wire = enc_helper.encode(probe)
await dev._notify_handler(None, bytearray(wire[:9]))
dev._write_packet.assert_not_awaited()
assert dev._state == _State.AUTH_STATUS
await dev._notify_handler(None, bytearray(wire[9:]))
dev._write_packet.assert_awaited_once()
auth = dev._write_packet.call_args.args[0]
assert auth.version == 2
assert auth.cmd_id == 0x86
assert len(auth.payload) == 32
assert dev._state == _State.AUTHENTICATING
success = EcoFlowPacket(0x35, 0x21, 0x35, 0x86, b"\x00", version=2)
wire = enc_helper.encode(success) + enc_helper.encode(heartbeat(2, pd_payload()))
await dev._notify_handler(None, bytearray(wire))
assert await dev.wait_authenticated(timeout=0.1)
assert dev.is_authenticated
assert dev.snapshot.ef_input_power_watts.raw_value == 160
# Read-only collection: no echo of telemetry as device commands.
dev._write_packet.assert_awaited_once()
asyncio.run(run())
def test_unrecognized_f001_is_not_auth_success(enc_helper):
async def run():
dev = device()
dev._state = _State.AUTHENTICATING
dev._assembler = enc_helper
enc = enc_helper._enc
wire = _enc_packet_to_bytes(1, b"\xf0\x01", enc.session_key, enc.iv)
await dev._on_data(bytearray(wire))
assert not dev.is_authenticated
assert dev._pkt_count == 0
rejected = EcoFlowPacket(0x35, 0x21, 0x35, 0x86, b"\x01", version=2)
await dev._on_data(bytearray(enc_helper.encode(rejected)))
assert dev._state == _State.ERROR
assert not dev._authenticated.is_set()
asyncio.run(run())
def test_disconnect_cancels_pending_auth_fallback():
async def run():
dev = device()
dev._state = _State.AUTH_STATUS
dev._do_authenticate = AsyncMock()
task = asyncio.create_task(dev._auth_type7_fallback())
dev._auth_fallback_task = task
await dev.disconnect()
assert task.cancelled()
assert dev._auth_fallback_task is None
dev._do_authenticate.assert_not_awaited()
asyncio.run(run())
@pytest.mark.parametrize("connect_ok,authenticated,packets", [
(False, False, 0), (True, False, 0), (True, True, 0), (True, True, 1),
])
def test_collection_always_disconnects(monkeypatch, connect_ok, authenticated, packets):
fake = Mock(
connect=AsyncMock(return_value=connect_ok),
wait_authenticated=AsyncMock(return_value=authenticated),
wait_for_measurements=AsyncMock(return_value=set()),
disconnect=AsyncMock(), _pkt_count=packets, last_error=None,
)
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
result = collect_data(initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0))
assert result is (fake.snapshot if packets else False)
fake.disconnect.assert_awaited_once()
@pytest.mark.parametrize("connect,authenticated,packets,reason", [
(AsyncMock(return_value=False), False, 0, "connect failed: BleakError: device not found"),
(AsyncMock(return_value=True), False, 0, "authentication did not complete in 30 seconds"),
(AsyncMock(return_value=True), True, 0, "no telemetry packets received"),
(AsyncMock(side_effect=RuntimeError("adapter gone")), False, 0, "RuntimeError: adapter gone"),
], ids=["connect", "authentication", "no-packets", "exception"])
def test_failed_collection_is_logged_without_a_debug_option(monkeypatch, capsys, connect, authenticated, packets, reason):
"""A device that is out of reach must show in the server log, not only as missing data."""
fake = Mock(
connect=connect, wait_authenticated=AsyncMock(return_value=authenticated),
wait_for_measurements=AsyncMock(return_value=set()),
disconnect=AsyncMock(), _pkt_count=packets,
last_error="connect failed: BleakError: device not found",
)
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
assert collect_data(config, debug=0) is False
assert f"ecoflow_bt [{ADDRESS}]: read failed: {reason}" in capsys.readouterr().out
def test_successful_collection_logs_no_failure(monkeypatch, capsys):
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
monkeypatch.setattr("bmspy.ecoflow_bt._last_missing", {})
fake = Mock(
connect=AsyncMock(return_value=True), wait_authenticated=AsyncMock(return_value=True),
wait_for_measurements=AsyncMock(return_value=set()),
disconnect=AsyncMock(), _pkt_count=3, last_error=None, snapshot=EcoFlowSnapshot(),
)
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
assert collect_data(config) is fake.snapshot
assert capsys.readouterr().out == ""
def _stoppable_device(monkeypatch, **steps):
"""Stand-in device that records whether its disconnect ran in full."""
events = []
async def disconnect():
events.append("disconnect started")
await asyncio.sleep(0.05)
events.append("disconnect done")
fake = Mock(
connect=AsyncMock(return_value=True), wait_authenticated=AsyncMock(return_value=True),
wait_for_measurements=AsyncMock(return_value=set()), disconnect=disconnect,
_pkt_count=1, last_error=None,
)
fake.configure_mock(**steps)
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
monkeypatch.setattr("bmspy.ecoflow_bt._STOP_POLL_SECONDS", 0.01)
monkeypatch.setattr("bmspy.ecoflow_bt._last_missing", {})
return fake, events
@pytest.mark.parametrize("step", ["connect", "wait_authenticated", "wait_for_measurements"])
def test_stop_during_a_collection_disconnects_before_it_returns(monkeypatch, capsys, step):
"""A server that stops in the middle of a read must not leave the link open."""
stop = threading.Event()
async def interrupted(*args, **kwargs):
stop.set()
await asyncio.sleep(30)
fake, events = _stoppable_device(monkeypatch, **{step: interrupted})
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
started = time.monotonic()
assert collect_data(config, stop=stop) is False
assert time.monotonic() - started < 5
assert events == ["disconnect started", "disconnect done"]
lines = capsys.readouterr().out.splitlines()
assert len(lines) == 1
assert lines[0].endswith(f"ecoflow_bt [{ADDRESS}]: read stopped, device disconnected")
def test_stop_during_the_disconnect_lets_the_disconnect_finish(monkeypatch):
stop = threading.Event()
fake, events = _stoppable_device(monkeypatch)
slow_disconnect = fake.disconnect
async def disconnect():
stop.set()
await slow_disconnect()
fake.disconnect = disconnect
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
assert collect_data(config, stop=stop) is fake.snapshot
assert events == ["disconnect started", "disconnect done"]
def test_collection_does_not_connect_after_stop(monkeypatch):
stop = threading.Event()
stop.set()
device = Mock()
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", device)
assert collect_data(initialise_device(ADDRESS, SERIAL, "test-user"), stop=stop) is False
device.assert_not_called()
def test_collection_with_an_unset_stop_event_returns_the_snapshot(monkeypatch):
fake, events = _stoppable_device(monkeypatch)
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
assert collect_data(config, stop=threading.Event()) is fake.snapshot
assert events == ["disconnect started", "disconnect done"]
def test_connect_records_the_reason_for_a_failure(monkeypatch):
client = Mock(connect=AsyncMock(side_effect=TimeoutError("no answer")), disconnect=AsyncMock(), is_connected=False)
monkeypatch.setitem(sys.modules, "bleak", SimpleNamespace(BleakClient=Mock(return_value=client)))
async def run():
dev = device()
assert not await dev.connect()
return dev.last_error
assert asyncio.run(run()) == "connect failed: TimeoutError: no answer"
def test_wait_ends_when_the_required_heartbeats_arrive(monkeypatch):
monkeypatch.setattr("bmspy.ecoflow_bt._COMPLETION_POLL_SECONDS", 0.01)
async def run():
dev = device()
dev._record(heartbeat(2, pd_payload()))
dev._record(heartbeat(3, ems_payload()))
async def late_bms():
await asyncio.sleep(0.03)
dev._record(heartbeat(3, bms_payload(), 0x32))
task = asyncio.create_task(late_bms())
loop = asyncio.get_running_loop()
start = loop.time()
# The late heartbeat extends the wait past the minimum, far below the limit.
missing = await dev.wait_for_measurements(0, 30)
await task
return missing, loop.time() - start
missing, elapsed = asyncio.run(run())
assert missing == set()
assert 0.03 <= elapsed < 5
def test_wait_keeps_the_minimum_for_optional_reports(monkeypatch):
monkeypatch.setattr("bmspy.ecoflow_bt._COMPLETION_POLL_SECONDS", 0.01)
async def run():
dev = device()
for packet in (heartbeat(2, pd_payload()), heartbeat(3, ems_payload()), heartbeat(3, bms_payload(), 0x32)):
dev._record(packet)
loop = asyncio.get_running_loop()
start = loop.time()
missing = await dev.wait_for_measurements(0.05, 30)
return missing, loop.time() - start
missing, elapsed = asyncio.run(run())
assert missing == set()
assert elapsed >= 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())