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:
@@ -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()
|
||||
Reference in New Issue
Block a user