Add River 2 Bluetooth telemetry and diagnostics

Decode River 2 charge, temperature, power flow, and time estimates. Preserve precise charge readings across packet reordering and omit unavailable time estimates.

Require valid PD, EMS, and BMS core heartbeats for diagnostic completion. Report optional packets separately and support raw watch output with reliable disconnect cleanup. Use synthetic regression tests and keep captures, credentials, and external keydata private.

Device details come from command-line arguments or discovery. This commit retains Poetry; the separate packaging commit migrates to uv.
This commit is contained in:
tim
2026-10-08 14:02:45 +02:00
parent 90388d2800
commit 886991caf3
16 changed files with 4093 additions and 11 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Client-visible EcoFlow protocol fields, using synthetic reports only."""
import io
import struct
from unittest.mock import MagicMock, patch
import pytest
from bmspy.classes import BMSInfoField, BMSScalarField
from bmspy.client import socket_comms
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket
from bmspy.ecoflow_river2 import MESSAGE_DEFINITIONS
def device(prefix):
return EcoFlowBTDevice("02:00:00:00:00:01", prefix + "TEST00000001", "test-user")
def record(dev, payload, route=(2, 0xFE, 0x15)):
src, cmd_set, cmd_id = route
dev._record(EcoFlowPacket(src, 0x21, cmd_set, cmd_id, payload))
return dict(dev.snapshot.items())
def heartbeat(definitions):
# Include skipped bytes, zeroes, and signed values, not just summary fields.
values = [bytes([0xAB]) * struct.calcsize("<" + f.fmt) if f.fmt.endswith("s")
else -3 if f.fmt in ("i", "h") else 1.5 if f.fmt == "f" else 0
for f in definitions]
return struct.pack("<" + "".join(f.fmt for f in definitions), *values), values
@pytest.mark.parametrize("route,namespace", [
((2, 0x20, 2), "pd"), ((3, 0x20, 2), "ems"),
((3, 0x20, 0x32), "bms"), ((4, 0x20, 2), "inv"), ((5, 0x20, 2), "mppt"),
])
def test_every_river2_layout_field_is_client_visible(route, namespace):
definitions = MESSAGE_DEFINITIONS[route][1]
payload, values = heartbeat(definitions)
fields = record(device("R601"), payload, route)
prefix = f"ef_river2_{namespace}_"
assert {key for key in fields if key.startswith(prefix)} == {
prefix + f.name for f in definitions
}
for definition, value in zip(definitions, values):
field = fields[prefix + definition.name]
if isinstance(value, bytes):
assert isinstance(field, BMSInfoField)
assert field.info == "0x" + value.hex()
else:
assert isinstance(field, BMSScalarField)
assert field.raw_value == value * definition.scale
assert field.units == (definition.unit or None)
def test_river2_short_firmware_reports_keep_previous_tail_and_explicit_zero():
dev = device("R603")
definitions = MESSAGE_DEFINITIONS[(2, 0x20, 2)][1]
payload, _ = heartbeat(definitions)
record(dev, payload + b"\x12\x34", (2, 0x20, 2))
fields = record(dev, payload[:23], (2, 0x20, 2))
assert fields["ef_river2_pd_watts_in_sum"].raw_value == 0
assert fields["ef_river2_pd_relay_switch_count"].raw_value == 0
assert fields["ef_river2_pd_trailing_bytes"].info == "0x1234"
assert fields["ef_river2_pd_remain_time"].raw_value == -3
assert "ef_remaining_time_minutes" not in fields
fresh = record(device("R601"), payload[:23], (2, 0x20, 2))
assert "ef_river2_pd_relay_switch_count" not in fresh
def test_river2_malformed_report_preserves_previous_protocol_fields():
dev = device("R601")
definitions = MESSAGE_DEFINITIONS[(3, 0x20, 2)][1]
payload, _ = heartbeat(definitions)
record(dev, payload, (3, 0x20, 2))
for bad in (payload[:24], payload[:26] + struct.pack("<f", float("nan"))):
fields = record(dev, bad, (3, 0x20, 2))
assert "ef_river2_ems_decode_error" in fields
assert fields["ef_river2_ems_f32_lcd_show_soc"].raw_value == 1.5
assert fields["ef_pkt_03_20_02"].info == bad.hex()
fields = record(dev, payload, (3, 0x20, 2))
assert "ef_river2_ems_decode_error" not in fields
@pytest.mark.parametrize("frame", [b"", b"\x00\x00", struct.pack("!I", 100) + b"{}"])
def test_incomplete_client_response_fails_without_looping(frame):
sock = MagicMock()
sock.recv.side_effect = io.BytesIO(frame).read
with patch("bmspy.client.socket.socket", return_value=sock), pytest.raises(SystemExit):
socket_comms("/fake/socket", {"command": "GET"})
sock.close.assert_called_once()
def test_connection_failure_does_not_read_from_unconnected_socket():
sock = MagicMock()
sock.connect.side_effect = ConnectionRefusedError("offline")
with patch("bmspy.client.socket.socket", return_value=sock), pytest.raises(ConnectionRefusedError):
socket_comms("/fake/socket", {"command": "GET"})
sock.recv.assert_not_called()
sock.sendall.assert_not_called()
sock.close.assert_called_once()