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.
886 lines
36 KiB
Python
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())
|