Add River 3 telemetry and schema-based diagnostics
Decode River 3 separately and accumulate partial reports against a field-level checklist. Keep watch mode running after completion. Label fields and nested records from the pinned pr705 schema. Preserve unknown values and document unverified meanings and routing. Add synthetic regressions, the River 3 field reference, schema-generation tooling, and upstream attribution.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""Backend import boundaries and compatibility, using synthetic packets only."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy import ecoflow_proto, ecoflow_river2, ecoflow_river3
|
||||
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
|
||||
|
||||
|
||||
PD_PACKET = (2, 0x20, 2, bytes(14) + struct.pack("<BHHi", 47, 0, 0, 5999))
|
||||
EMS_PACKET = (3, 0x20, 2, bytes(26) + struct.pack("<f", 87.5))
|
||||
CELL_TEMP_PACKET = (2, 0xFE, 0x15, b"\x98\x10\x1d") # field 259: 29 C
|
||||
COMPOSITE_TEMP_PACKET = (2, 0xFE, 0x15, b"\xb0\x06\x14") # field 102: 20 C
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first", [
|
||||
"ecoflow_proto", "ecoflow_river2", "ecoflow_river3", "ecoflow_snapshot",
|
||||
])
|
||||
def test_decoders_import_in_any_order_without_transport_dependencies(first):
|
||||
# A fresh interpreter catches import cycles hidden by pytest's import order.
|
||||
script = """
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
class NoTransportImports(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname.split('.')[0] in {'serial', 'bleak', 'ecdsa', 'Crypto'}:
|
||||
raise AssertionError('Unexpected optional dependency: ' + fullname)
|
||||
if fullname in {'bmspy.ecoflow_bt', 'bmspy.jbd_bms'}:
|
||||
raise AssertionError('Unexpected transport import: ' + fullname)
|
||||
|
||||
sys.meta_path.insert(0, NoTransportImports())
|
||||
importlib.import_module('bmspy.' + sys.argv[1])
|
||||
from bmspy import ecoflow_proto, ecoflow_river2, ecoflow_river3
|
||||
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
|
||||
|
||||
assert ecoflow_proto.format_payload(2, 0xFE, 0x15, b'\\x08\\x00') == [
|
||||
'[DisplayPropertyUpload]', ' errcode: 0',
|
||||
]
|
||||
assert ecoflow_proto.format_payload(2, 0x20, 2, bytes(23))[0] == '[PD heartbeat]'
|
||||
assert ecoflow_river2.decode_river2_payload(2, 0x20, 2, bytes(23))['soc'] == 0
|
||||
assert ecoflow_river3.decode_river3_payload(2, 0xFE, 0x15, b'\\x08\\x00') == {}
|
||||
assert list(EcoFlowSnapshot().items()) == []
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script, first],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_snapshot_and_decoder_imports_remain_compatible():
|
||||
from bmspy import ecoflow_bt
|
||||
|
||||
assert ecoflow_bt.EcoFlowSnapshot is EcoFlowSnapshot
|
||||
assert ecoflow_bt.decode_river2_payload is ecoflow_river2.decode_river2_payload
|
||||
assert ecoflow_bt.decode_river3_payload is ecoflow_river3.decode_river3_payload
|
||||
|
||||
|
||||
def test_diagnostic_formatting_aliases_remain_compatible():
|
||||
assert ecoflow_proto.decode_payload is ecoflow_proto.format_payload
|
||||
assert ecoflow_proto.decode_protobuf is ecoflow_proto.format_protobuf
|
||||
assert ecoflow_proto.decode_schema_protobuf is ecoflow_proto.format_schema_protobuf
|
||||
assert ecoflow_proto.format_protobuf(b"\x08\x00") == ["field 1: 0"]
|
||||
assert ecoflow_proto.format_schema_protobuf(b"\x08\x00", "DisplayPropertyUpload") == [
|
||||
"errcode: 0",
|
||||
]
|
||||
assert ecoflow_proto.format_payload(2, 0xFE, 0x15, b"") is None
|
||||
assert ecoflow_proto.format_payload(0x35, 0x35, 0x20, b"\x00") is None
|
||||
assert ecoflow_proto.describe_message(2, 0x20, 2) == "PD heartbeat"
|
||||
assert ecoflow_proto.describe_message(2, 0xFE, 0x15) == "DisplayPropertyUpload"
|
||||
assert ecoflow_proto.describe_message(0x35, 0x35, 0x20) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,packets", [
|
||||
(ecoflow_river2, [PD_PACKET, EMS_PACKET, CELL_TEMP_PACKET, (2, 0x20, 2, b"")]),
|
||||
(ecoflow_river3, [CELL_TEMP_PACKET, COMPOSITE_TEMP_PACKET, PD_PACKET, (2, 0xFE, 0x15, b"\x80")]),
|
||||
])
|
||||
def test_legacy_numeric_decoder_entry_points(model, packets):
|
||||
name = "decode_" + model.__name__.rsplit("_", 1)[1] + "_payload"
|
||||
legacy = getattr(ecoflow_proto, name)
|
||||
direct = getattr(model, name)
|
||||
for packet in packets:
|
||||
assert legacy(*packet) == direct(*packet)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decoder_type,high_packet,low_packet,field,high,low", [
|
||||
(ecoflow_river2.River2Decoder, EMS_PACKET, PD_PACKET, "bms_capacity_charge_ratio", 0.875, 0.47),
|
||||
(ecoflow_river3.River3Decoder, CELL_TEMP_PACKET, COMPOSITE_TEMP_PACKET, "bms_temperature_celcius", 29, 20),
|
||||
], ids=["river2_soc", "river3_temperature"])
|
||||
def test_measurement_priorities_are_per_instance_and_reset_per_connection(
|
||||
decoder_type, high_packet, low_packet, field, high, low,
|
||||
):
|
||||
def value(snapshot):
|
||||
measurement = getattr(snapshot, field)
|
||||
return measurement.raw_values[1] if hasattr(measurement, "raw_values") else measurement.raw_value
|
||||
|
||||
decoder = decoder_type()
|
||||
snapshot = EcoFlowSnapshot()
|
||||
decoder.update(snapshot, *high_packet)
|
||||
decoder.update(snapshot, *low_packet)
|
||||
assert value(snapshot) == high
|
||||
|
||||
other = decoder_type()
|
||||
other_snapshot = EcoFlowSnapshot()
|
||||
other.update(other_snapshot, *low_packet)
|
||||
assert value(other_snapshot) == low
|
||||
assert value(snapshot) == high
|
||||
|
||||
before = asdict(snapshot)
|
||||
decoder.reset()
|
||||
assert asdict(snapshot) == before
|
||||
decoder.update(snapshot, *low_packet)
|
||||
assert value(snapshot) == low
|
||||
|
||||
|
||||
def test_snapshot_keeps_diagnostic_packets_and_json_round_trip():
|
||||
from bmspy.classes import UPS
|
||||
|
||||
snapshot = EcoFlowSnapshot()
|
||||
ecoflow_river2.River2Decoder().update(snapshot, *PD_PACKET)
|
||||
snapshot.record_packet(*PD_PACKET)
|
||||
items = dict(snapshot.items())
|
||||
assert items["bms_capacity_charge_ratio"].raw_value == 0.47
|
||||
assert items["ef_pkt_02_20_02"].info == PD_PACKET[3].hex()
|
||||
assert "ef_remaining_time_minutes" not in items
|
||||
wire_data = {name: asdict(field) for name, field in snapshot.items()}
|
||||
restored = UPS.from_dict(json.loads(json.dumps(wire_data)))
|
||||
assert dict(restored.items()) == items
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Synthetic collection/watch tests. No Bluetooth hardware or private captures."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket, _State
|
||||
from bmspy.ecoflow_proto import parse_protobuf_fields
|
||||
|
||||
ADDRESS = "02:00:00:00:00:03"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def script():
|
||||
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
|
||||
spec = importlib.util.spec_from_file_location("ecoflow_live_diagnostics", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def varint(value):
|
||||
result = bytearray()
|
||||
while value > 127:
|
||||
result.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
result.append(value)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def wire_field(number, wire_type=0, value=0):
|
||||
body = struct.pack("<f", value) if wire_type == 5 else varint(value)
|
||||
return varint(number << 3 | wire_type) + body
|
||||
|
||||
|
||||
def report(payload, src=2, cmd_set=0xFE, cmd_id=0x15):
|
||||
return EcoFlowPacket(src, 0x21, cmd_set, cmd_id, payload)
|
||||
|
||||
|
||||
def required_payload(script, group=None, omit=()):
|
||||
return b"".join(
|
||||
wire_field(number, wire_type)
|
||||
for name, fields in script.RIVER3_REQUIRED_GROUPS.items()
|
||||
if group is None or group == name
|
||||
for number, wire_type in fields.items() if number not in omit
|
||||
)
|
||||
|
||||
|
||||
def device(prefix="R651"):
|
||||
return EcoFlowBTDevice(ADDRESS, prefix + "TEST00000001", "test-user")
|
||||
|
||||
|
||||
def attach(script, dev, watch=False):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES[dev.serial_number[:4]], watch)
|
||||
dev.on_packet(collector.receive)
|
||||
return collector
|
||||
|
||||
|
||||
def river2_bypass_packets():
|
||||
"""Synthetic first burst: three core heartbeats, MPPT, and an empty reply."""
|
||||
pd = bytearray(121)
|
||||
struct.pack_into("<BHHi", pd, 14, 88, 35, 35, 5218)
|
||||
ems = bytearray(46)
|
||||
struct.pack_into("<II", ems, 17, 5999, 5999)
|
||||
struct.pack_into("<f", ems, 26, 87.5)
|
||||
bms = bytearray(69)
|
||||
bms[11], bms[20] = 88, 28
|
||||
struct.pack_into("<I", bms, 12, 13)
|
||||
struct.pack_into("<fIII", bms, 53, 87.75, 0, 0, 0)
|
||||
return [
|
||||
EcoFlowPacket(2, 0x21, 0x20, 2, bytes(pd), version=2),
|
||||
EcoFlowPacket(0x35, 0x21, 1, 0x52, b"", version=2),
|
||||
EcoFlowPacket(3, 0x21, 0x20, 2, bytes(ems), version=2),
|
||||
EcoFlowPacket(3, 0x21, 0x20, 0x32, bytes(bms), version=2),
|
||||
EcoFlowPacket(5, 0x21, 0x20, 2, bytes(94), version=2),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", [("measurements", "configuration"), ("configuration", "measurements")])
|
||||
def test_checklist_combines_reports_in_either_order_and_counts_zero(script, order, capsys):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||
collector.receive(report(required_payload(script, order[0])))
|
||||
assert collector.missing()
|
||||
first_output = capsys.readouterr().out
|
||||
assert "DisplayPropertyUpload" in first_output # Printed before collection finishes.
|
||||
collector.receive(report(required_payload(script, order[1])))
|
||||
assert collector.missing() == set()
|
||||
assert collector.progress() == "measurements 31/31, configuration 22/22"
|
||||
assert "DisplayPropertyUpload" in capsys.readouterr().out
|
||||
lines = "\n".join(collector.decoded_packets()["ef_pkt_02_fe_15"])
|
||||
assert "pow_in_sum_w: 0.000 W" in lines
|
||||
assert "plug_in_info_ac_in_chg_pow_max: 0 W" in lines
|
||||
|
||||
|
||||
def test_only_new_or_changed_fields_print_in_normal_mode(script, capsys):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||
packet = report(wire_field(262, 5, 89))
|
||||
collector.receive(packet)
|
||||
assert "cms_batt_soc: 89.000 %" in capsys.readouterr().out
|
||||
collector.receive(packet)
|
||||
assert capsys.readouterr().out == ""
|
||||
collector.receive(report(wire_field(262, 5, 88)))
|
||||
assert "cms_batt_soc: 88.000 %" in capsys.readouterr().out
|
||||
assert len(collector.fields) == 1
|
||||
|
||||
|
||||
def test_unavailable_time_counts_as_received_not_missing(script):
|
||||
dev = device()
|
||||
collector = attach(script, dev)
|
||||
dev._record(report(required_payload(script, omit=(268,))))
|
||||
assert "measurements:cms_dsg_rem_time" in collector.missing()
|
||||
dev._record(report(wire_field(268, value=0xFFFFFFFF)))
|
||||
assert collector.missing() == set()
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", [(3, 0xFE, 0x15), (2, 0xFE, 0x16), (2, 0x20, 2)])
|
||||
def test_unrelated_routes_cannot_satisfy_river3_checklist(script, route):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||
original = collector.missing()
|
||||
collector.receive(report(required_payload(script), *route))
|
||||
assert collector.missing() == original
|
||||
assert collector.fields == {}
|
||||
|
||||
|
||||
def test_malformed_packet_is_atomic_and_wrong_wire_type_does_not_count(script):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||
collector.receive(report(required_payload(script) + b"\x80"))
|
||||
assert not collector.fields
|
||||
assert not collector.seen
|
||||
assert collector.error is None
|
||||
collector.receive(report(required_payload(script, omit=(3,)) + wire_field(3)))
|
||||
assert collector.missing() == {"measurements:pow_in_sum_w"}
|
||||
assert 3 not in collector.fields
|
||||
collector.receive(report(wire_field(3, 5)))
|
||||
assert not collector.missing()
|
||||
|
||||
|
||||
def test_nested_repeated_unknown_fields_remain_grouped_and_optional(script):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||
child = wire_field(1, value=7)
|
||||
item = varint(463 << 3 | 2) + varint(len(child)) + child
|
||||
collector.receive(report(item + item))
|
||||
assert collector.fields[463] == item + item
|
||||
assert len(parse_protobuf_fields(collector.fields[463])) == 2
|
||||
assert len(collector.missing()) == 53
|
||||
collector.receive(report(required_payload(script)))
|
||||
assert not collector.missing()
|
||||
assert collector.fields[463] == item + item
|
||||
collector.receive(report(item))
|
||||
assert collector.fields[463] == item # Replace the repeated group, do not append forever.
|
||||
assert not collector.missing()
|
||||
|
||||
|
||||
def test_each_packet_callback_runs_after_snapshot_updates_even_for_duplicates():
|
||||
dev = device()
|
||||
packets, updates = [], []
|
||||
dev.on_packet(Mock(side_effect=ValueError("bad listener")))
|
||||
dev.on_packet(lambda pkt: packets.append((pkt, dev.snapshot.ef_input_power_watts.raw_value)))
|
||||
dev.on_update(lambda: updates.append(True))
|
||||
pkt = report(wire_field(3, 5, 12))
|
||||
dev._record(pkt)
|
||||
dev._record(pkt)
|
||||
assert packets == [(pkt, 12), (pkt, 12)]
|
||||
assert updates == [True, True]
|
||||
|
||||
|
||||
def test_completed_reports_finish_without_waiting_for_a_deadline(script, monkeypatch, capsys):
|
||||
dev = device()
|
||||
collector = attach(script, dev)
|
||||
dev._record(report(required_payload(script)))
|
||||
sleep = AsyncMock(side_effect=AssertionError("must not wait"))
|
||||
monkeypatch.setattr(script.asyncio, "sleep", sleep)
|
||||
asyncio.run(script.collect([(ADDRESS, dev, set())], max_wait=0, collectors={ADDRESS: collector}))
|
||||
sleep.assert_not_awaited()
|
||||
assert "all required information received" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_timeout_reports_named_missing_fields_without_claiming_completion(script, capsys):
|
||||
dev = device()
|
||||
collector = attach(script, dev)
|
||||
dev._record(report(required_payload(script, omit=(209,))))
|
||||
asyncio.run(script.collect([(ADDRESS, dev, set())], max_wait=0, collectors={ADDRESS: collector}))
|
||||
out = capsys.readouterr().out
|
||||
assert "timed out, incomplete" in out
|
||||
assert "configuration:plug_in_info_ac_in_chg_pow_max" in out
|
||||
assert "all required information received" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["R601", "R651"])
|
||||
def test_watch_continues_after_completion_and_timeout_and_includes_late_unknown_packets(
|
||||
script, monkeypatch, capsys, prefix,
|
||||
):
|
||||
dev = device(prefix)
|
||||
dev._state = _State.AUTHENTICATED
|
||||
collector = attach(script, dev, watch=True)
|
||||
if prefix == "R651":
|
||||
dev._record(report(required_payload(script)))
|
||||
else:
|
||||
for src, cmd_id in [(2, 2), (3, 2), (3, 0x32)]:
|
||||
dev._record(EcoFlowPacket(src, 0x21, 0x20, cmd_id, bytes(128), version=2))
|
||||
calls = 0
|
||||
|
||||
async def receive_later(_):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
dev._record(report(b"test-only-unknown", src=0x35, cmd_set=0x35, cmd_id=0x20))
|
||||
dev._record(report(wire_field(999, value=7)))
|
||||
else:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(script.asyncio, "sleep", receive_later)
|
||||
expected = script.MODEL_PROFILES[prefix]["expected_fields"]
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0,
|
||||
collectors={ADDRESS: collector}, watch=True))
|
||||
out = capsys.readouterr().out
|
||||
assert calls == 2
|
||||
assert "continuing watch for optional or unknown information" in out
|
||||
assert "ef_pkt_35_35_20" in out
|
||||
assert "raw_payload: " + b"test-only-unknown".hex() in out
|
||||
assert "No decoded fields; retain raw payload for review." in out
|
||||
assert "field 999: 7" in out
|
||||
assert "timed out" not in out
|
||||
|
||||
|
||||
def test_watch_detects_disconnection_even_after_completion(script):
|
||||
dev = device()
|
||||
collector = attach(script, dev, watch=True)
|
||||
dev._record(report(required_payload(script)))
|
||||
with pytest.raises(ConnectionError, match="not authenticated"):
|
||||
asyncio.run(script.collect([(ADDRESS, dev, set())], max_wait=0,
|
||||
collectors={ADDRESS: collector}, watch=True))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["R601", "R651"])
|
||||
@pytest.mark.parametrize("watch,failure", [
|
||||
(False, None), (False, "output"), (True, "cancel"), (True, "output"), (True, "disconnect"),
|
||||
])
|
||||
def test_run_registers_before_first_burst_and_always_disconnects(script, monkeypatch, capsys, watch, failure, prefix):
|
||||
dev = device(prefix)
|
||||
|
||||
async def connect(**_):
|
||||
dev._state = _State.AUTHENTICATED
|
||||
if prefix == "R601":
|
||||
for packet in river2_bypass_packets():
|
||||
dev._record(packet)
|
||||
else:
|
||||
dev._record(report(required_payload(script, "measurements")))
|
||||
dev._record(report(required_payload(script, "configuration")))
|
||||
if failure == "disconnect":
|
||||
dev._state = _State.DISCONNECTED
|
||||
|
||||
manager = Mock(add_device=Mock(return_value=dev), connect_all=AsyncMock(side_effect=connect),
|
||||
disconnect_all=AsyncMock())
|
||||
monkeypatch.setattr(script, "EcoFlowBTManager", Mock(return_value=manager))
|
||||
if failure == "cancel":
|
||||
monkeypatch.setattr(script.asyncio, "sleep", AsyncMock(side_effect=asyncio.CancelledError()))
|
||||
if failure == "output":
|
||||
monkeypatch.setattr(script.PacketCollector, "_receive", Mock(side_effect=OSError("output failed")))
|
||||
args = Namespace(scan=False, device=ADDRESS, serial_number=dev.serial_number,
|
||||
debug=0, timeout=0, watch=watch)
|
||||
if failure:
|
||||
error = {"cancel": asyncio.CancelledError, "output": OSError, "disconnect": ConnectionError}[failure]
|
||||
with pytest.raises(error):
|
||||
asyncio.run(script.run("test-user", args))
|
||||
else:
|
||||
asyncio.run(script.run("test-user", args))
|
||||
out = capsys.readouterr().out
|
||||
final = out.split("Snapshot:")[1]
|
||||
if prefix == "R601":
|
||||
assert "3/3 required heartbeats received (PD, EMS, BMS)" in out
|
||||
assert "Other packet types seen: ef_pkt_35_01_52 (empty payload)" in out
|
||||
assert "[PD heartbeat]" in final
|
||||
assert "latest fields collected this run" not in final
|
||||
else:
|
||||
assert "all required information received" in out
|
||||
assert "latest fields collected this run" in final
|
||||
assert "cms_batt_soc: 0.000 %" in final
|
||||
assert "plug_in_info_ac_in_chg_pow_max: 0 W" in final
|
||||
assert "timed out" not in out
|
||||
manager.disconnect_all.assert_awaited_once()
|
||||
@@ -1,15 +1,51 @@
|
||||
"""Client-visible EcoFlow protocol fields, using synthetic reports only."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.classes import BMSInfoField, BMSScalarField
|
||||
from bmspy.client import socket_comms
|
||||
from bmspy.client import read_data, socket_comms
|
||||
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket
|
||||
from bmspy.ecoflow_proto import decode_schema_values
|
||||
from bmspy.ecoflow_river2 import MESSAGE_DEFINITIONS
|
||||
from bmspy.ecoflow_river3_schema import ENUMS, MESSAGES
|
||||
from bmspy.server import send_response
|
||||
|
||||
|
||||
def varint(value):
|
||||
value &= (1 << 64) - 1
|
||||
result = bytearray()
|
||||
while value > 127:
|
||||
result.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
result.append(value)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def integer(number, value):
|
||||
return varint(number << 3) + varint(value)
|
||||
|
||||
|
||||
def floating(number, value):
|
||||
return varint(number << 3 | 5) + struct.pack("<f", value)
|
||||
|
||||
|
||||
def blob(number, value):
|
||||
return varint(number << 3 | 2) + varint(len(value)) + value
|
||||
|
||||
|
||||
def sample(number, kind):
|
||||
if kind in MESSAGES or kind == "string":
|
||||
return blob(number, b"")
|
||||
if kind == "float":
|
||||
return floating(number, 1.5)
|
||||
assert kind in ("bool", "uint32", "int32") or kind in ENUMS
|
||||
return integer(number, 0)
|
||||
|
||||
|
||||
def device(prefix):
|
||||
@@ -82,6 +118,143 @@ def test_river2_malformed_report_preserves_previous_protocol_fields():
|
||||
assert "ef_river2_ems_decode_error" not in fields
|
||||
|
||||
|
||||
def test_every_river3_display_field_is_client_visible_without_defaults():
|
||||
for number, (name, kind, repeated) in MESSAGES["DisplayPropertyUpload"].items():
|
||||
fields = record(device("R651"), sample(number, kind))
|
||||
protocol = {k: v for k, v in fields.items() if k.startswith("ef_river3_display_")}
|
||||
key = "ef_river3_display_" + name
|
||||
assert set(protocol) == {key}
|
||||
field = protocol[key]
|
||||
if repeated or kind in MESSAGES:
|
||||
assert isinstance(field, BMSInfoField)
|
||||
assert json.loads(field.info) == ([{}] if repeated else {})
|
||||
elif kind == "string":
|
||||
assert field.info == ""
|
||||
else:
|
||||
assert isinstance(field, BMSScalarField)
|
||||
assert field.raw_value == (1.5 if kind == "float" else 0)
|
||||
if kind == "bool":
|
||||
assert field.raw_value is False
|
||||
|
||||
|
||||
def test_river3_partial_reports_preserve_values_and_do_not_invent_units():
|
||||
dev = device("R651")
|
||||
record(dev, floating(11, -14.322) + floating(262, 89))
|
||||
fields = record(dev, integer(25, 0) + integer(258, -7))
|
||||
assert fields["ef_river3_display_pow_get_typec1"].raw_value == pytest.approx(-14.322)
|
||||
assert fields["ef_river3_display_pow_get_typec1"].units == "W"
|
||||
assert fields["ef_river3_display_cms_batt_soc"].raw_value == 89
|
||||
assert fields["ef_river3_display_cms_batt_soc"].units == "%"
|
||||
assert fields["bms_capacity_charge_ratio"].raw_value == .89
|
||||
assert fields["ef_river3_display_xboost_en"].raw_value is False
|
||||
assert fields["ef_river3_display_bms_min_cell_temp"].raw_value == -7
|
||||
assert fields["ef_river3_display_bms_min_cell_temp"].units == "°C"
|
||||
fields = record(dev, integer(269, 0xFFFFFFFF))
|
||||
assert fields["ef_river3_display_cms_chg_rem_time"].raw_value == 0xFFFFFFFF
|
||||
assert "ef_charge_remaining_time_minutes" not in fields
|
||||
|
||||
|
||||
def test_nested_repeated_enum_strings_and_empty_values_keep_their_structure():
|
||||
dev = device("R651")
|
||||
stats = blob(1, integer(1, 2) + integer(2, 17)) + blob(1, integer(1, 999)) + blob(1, b"")
|
||||
fields = record(dev, blob(463, stats) + blob(433, b"") + blob(91, b""))
|
||||
value = json.loads(fields["ef_river3_display_display_statistics_sum"].info)
|
||||
assert value == {"list_info": [
|
||||
{"statistics_object": {"code": 2, "name": "STATISTICS_OBJECT_AC_OUT_ENERGY"}, "statistics_content": 17},
|
||||
{"statistics_object": {"code": 999, "name": None}}, {},
|
||||
]}
|
||||
assert fields["ef_river3_display_plug_in_info_dcp_sn"].info == ""
|
||||
assert fields["ef_river3_display_plug_in_info_dcp2_resv"].info == "{}"
|
||||
# A later report replaces a reported group, rather than keeping stale children.
|
||||
fields = record(dev, blob(463, b""))
|
||||
assert fields["ef_river3_display_display_statistics_sum"].info == "{}"
|
||||
|
||||
|
||||
def test_packed_unpacked_and_duplicate_message_fields_merge_within_report():
|
||||
packed = blob(1, varint(0) + varint(128))
|
||||
payload = blob(432, packed) + blob(432, integer(1, 7))
|
||||
values = decode_schema_values(payload, "DisplayPropertyUpload")
|
||||
assert values["plug_in_info_dcp_resv"] == {"resv_info": [0, 128, 7]}
|
||||
empty = decode_schema_values(blob(432, blob(1, b"")), "DisplayPropertyUpload")
|
||||
assert empty["plug_in_info_dcp_resv"] == {"resv_info": []}
|
||||
assert decode_schema_values(floating(3, 1) + floating(3, 2), "DisplayPropertyUpload")["pow_in_sum_w"] == 2
|
||||
|
||||
|
||||
def test_unknown_fields_keep_all_wire_encodings_and_repetitions():
|
||||
unknown = integer(999, 7) + integer(999, 0)
|
||||
unknown += blob(1000, b"opaque") + floating(1001, 1.5)
|
||||
unknown += varint(1002 << 3 | 1) + bytes(8)
|
||||
fields = record(device("R651"), unknown)
|
||||
for number, wire in ((999, 0), (1000, 2), (1001, 5), (1002, 1)):
|
||||
items = json.loads(fields[f"ef_river3_display_field_{number}"].info)
|
||||
assert all(item["wire_type"] == wire for item in items)
|
||||
assert all(bytes.fromhex(item["raw"]) in unknown for item in items)
|
||||
assert [item["value"] for item in json.loads(fields["ef_river3_display_field_999"].info)] == [7, 0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
b"\x00", b"\x80", integer(4, 1), floating(4, float("inf")),
|
||||
integer(269, 1 << 32), blob(433, b"\xff"), blob(141, b"\x00"),
|
||||
blob(432, blob(1, b"\x80")),
|
||||
])
|
||||
def test_invalid_river3_protocol_report_is_atomic_and_recoverable(bad):
|
||||
dev = device("R651")
|
||||
record(dev, floating(3, 10) + floating(4, 20))
|
||||
payload = floating(3, 99) + bad
|
||||
fields = record(dev, payload)
|
||||
assert fields["ef_river3_display_pow_in_sum_w"].raw_value == 10
|
||||
assert fields["ef_river3_display_pow_out_sum_w"].raw_value == 20
|
||||
assert "ef_river3_display_decode_error" in fields
|
||||
assert fields["ef_pkt_02_fe_15"].info == payload.hex()
|
||||
fields = record(dev, floating(3, 0))
|
||||
assert fields["ef_river3_display_pow_in_sum_w"].raw_value == 0
|
||||
assert fields["ef_river3_display_pow_out_sum_w"].raw_value == 20
|
||||
assert "ef_river3_display_decode_error" not in fields
|
||||
|
||||
|
||||
def test_schema_value_decoding_has_a_nesting_limit(monkeypatch):
|
||||
monkeypatch.setitem(MESSAGES, "Recursive", {1: ("child", "Recursive", False)})
|
||||
payload = b""
|
||||
for _ in range(20):
|
||||
payload = blob(1, payload)
|
||||
with pytest.raises(ValueError, match="maximum nesting depth"):
|
||||
decode_schema_values(payload, "Recursive")
|
||||
|
||||
|
||||
def test_unconfirmed_runtime_and_unknown_routes_remain_raw_only():
|
||||
dev = device("R651")
|
||||
for route in ((2, 0xFE, 0x16), (3, 0xFE, 0x15), (0x35, 0x35, 0x20)):
|
||||
fields = record(dev, floating(244, 19.2), route)
|
||||
assert not any(key.startswith("ef_river3_") for key in fields)
|
||||
assert fields[f"ef_pkt_{route[0]:02x}_{route[1]:02x}_{route[2]:02x}"].info
|
||||
|
||||
|
||||
def full_snapshots():
|
||||
river2, river3 = device("R601"), device("R651")
|
||||
for route, (_, definitions, _) in MESSAGE_DEFINITIONS.items():
|
||||
record(river2, heartbeat(definitions)[0], route)
|
||||
record(river3, b"".join(sample(number, kind) for number, (_, kind, _) in MESSAGES["DisplayPropertyUpload"].items()))
|
||||
return {"desktop": river2.snapshot, "nas": river3.snapshot}
|
||||
|
||||
|
||||
def test_all_fields_survive_large_fragmented_server_response_to_client():
|
||||
snapshots = full_snapshots()
|
||||
server_socket = MagicMock()
|
||||
send_response(server_socket, snapshots, "test")
|
||||
frame = server_socket.sendall.call_args.args[0]
|
||||
assert len(frame) > 65536
|
||||
stream = io.BytesIO(frame)
|
||||
sock = MagicMock()
|
||||
sock.recv.side_effect = lambda n: stream.read(min(n, 2 if stream.tell() < 4 else 127))
|
||||
with patch("bmspy.client.socket.socket", return_value=sock):
|
||||
result = read_data("/fake/socket", "test")
|
||||
expected = json.loads(frame[4:])
|
||||
actual = {device: {key: asdict(value) for key, value in snap.items()} for device, snap in result.items()}
|
||||
assert actual == expected
|
||||
assert stream.read() == b""
|
||||
sock.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", [b"", b"\x00\x00", struct.pack("!I", 100) + b"{}"])
|
||||
def test_incomplete_client_response_fails_without_looping(frame):
|
||||
sock = MagicMock()
|
||||
@@ -99,3 +272,67 @@ def test_connection_failure_does_not_read_from_unconnected_socket():
|
||||
sock.recv.assert_not_called()
|
||||
sock.sendall.assert_not_called()
|
||||
sock.close.assert_called_once()
|
||||
|
||||
|
||||
def test_all_fields_reach_influxdb():
|
||||
pytest.importorskip("influxdb_client_3")
|
||||
from bmspy.influxdb import influxdb_create_snapshot
|
||||
|
||||
snapshots = full_snapshots()
|
||||
points = influxdb_create_snapshot(snapshots)
|
||||
expected = {(ups, key) for ups, snap in snapshots.items() for key, _ in snap.items()}
|
||||
assert {(point._tags["ups"], point._name) for point in points} == expected
|
||||
for point in points:
|
||||
field = dict(snapshots[point._tags["ups"]].items())[point._name]
|
||||
value = (field.info if isinstance(field, BMSInfoField) else field.raw_value
|
||||
if isinstance(field, BMSScalarField) else field.raw_values[1])
|
||||
assert point._fields["value"] == value
|
||||
assert point.to_line_protocol()
|
||||
|
||||
|
||||
def test_all_fields_reach_prometheus():
|
||||
prometheus = pytest.importorskip("prometheus_client")
|
||||
from bmspy.prometheus import prometheus_create_metric, prometheus_populate_metric
|
||||
|
||||
snapshots = full_snapshots()
|
||||
registry = prometheus.CollectorRegistry()
|
||||
metrics = prometheus_create_metric(registry, snapshots)
|
||||
prometheus_populate_metric(metrics, snapshots)
|
||||
assert set(metrics) == {key for snap in snapshots.values() for key, _ in snap.items()}
|
||||
for ups, snap in snapshots.items():
|
||||
for key, field in snap.items():
|
||||
labels = {"ups": ups}
|
||||
if isinstance(field, BMSInfoField):
|
||||
labels[key] = field.info
|
||||
assert registry.get_sample_value(key + "_info", labels) == 1
|
||||
elif isinstance(field, BMSScalarField):
|
||||
assert registry.get_sample_value(key, labels) == field.raw_value
|
||||
else:
|
||||
for index, value in field.raw_values.items():
|
||||
assert registry.get_sample_value(key, {**labels, field.label: str(index)}) == value
|
||||
|
||||
|
||||
def test_prometheus_daemon_adds_fields_from_later_reports():
|
||||
prometheus = pytest.importorskip("prometheus_client")
|
||||
from bmspy.prometheus import prometheus_export
|
||||
|
||||
first, later = device("R651"), device("R651")
|
||||
record(first, floating(3, 10))
|
||||
record(later, floating(3, 20) + floating(11, -14))
|
||||
snapshots = [{"nas": first.snapshot}, {"nas": later.snapshot}]
|
||||
captured = []
|
||||
|
||||
def capture(port, registry):
|
||||
captured.append(registry)
|
||||
|
||||
class Finished(Exception):
|
||||
pass
|
||||
|
||||
with patch("bmspy.prometheus.client.read_data", side_effect=snapshots), \
|
||||
patch.object(prometheus, "start_http_server", side_effect=capture), \
|
||||
patch("bmspy.prometheus.time.sleep", side_effect=[None, Finished]):
|
||||
with pytest.raises(Finished):
|
||||
prometheus_export()
|
||||
registry = captured[0]
|
||||
assert registry.get_sample_value("ef_river3_display_pow_in_sum_w", {"ups": "nas"}) == 20
|
||||
assert registry.get_sample_value("ef_river3_display_pow_get_typec1", {"ups": "nas"}) == -14
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""River 3 tests using synthetic protobuf messages, not device captures.
|
||||
|
||||
Field numbers/types follow ha-ef-ble's proto/pr705.proto DisplayPropertyUpload.
|
||||
The packet fixtures model the XOR framing used by upstream River 3 tests.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.classes import UPS
|
||||
from bmspy.ecoflow_bt import (
|
||||
EcoFlowBTDevice, EcoFlowPacket, _EncFrameHelper, _State, _Type7Encryption,
|
||||
)
|
||||
from bmspy.ecoflow_river3 import decode_river3_payload
|
||||
|
||||
ADDRESS = "02:00:00:00:00:03"
|
||||
SERIAL = "R651TEST00000001"
|
||||
|
||||
|
||||
def varint(value):
|
||||
value &= (1 << 64) - 1
|
||||
data = bytearray()
|
||||
while value > 127:
|
||||
data.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
data.append(value)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def integer(field, value):
|
||||
return varint(field << 3) + varint(value)
|
||||
|
||||
|
||||
def floating(field, value):
|
||||
return varint((field << 3) | 5) + struct.pack("<f", value)
|
||||
|
||||
|
||||
def report(payload):
|
||||
return EcoFlowPacket(2, 0x21, 0xFE, 0x15, payload, version=3)
|
||||
|
||||
|
||||
def device(serial=SERIAL):
|
||||
return EcoFlowBTDevice(ADDRESS, serial, "test-user")
|
||||
|
||||
|
||||
def display_payload(power=166):
|
||||
return b"".join([
|
||||
floating(262, 88.918), floating(3, 244), floating(4, 39),
|
||||
floating(158, power), integer(259, 29), integer(269, 10),
|
||||
integer(268, 184), integer(282, 1),
|
||||
])
|
||||
|
||||
|
||||
def test_decode_numeric_display_fields():
|
||||
values = decode_river3_payload(2, 0xFE, 0x15, display_payload())
|
||||
assert values["cms_batt_soc"] == pytest.approx(88.918)
|
||||
assert values["pow_in_sum_w"] == 244
|
||||
assert values["pow_out_sum_w"] == 39
|
||||
assert values["pow_get_bms"] == 166
|
||||
assert values["bms_max_cell_temp"] == 29
|
||||
assert values["cms_chg_rem_time"] == 10
|
||||
assert values["cms_dsg_rem_time"] == 184
|
||||
assert values["cms_chg_dsg_state"] == 1
|
||||
assert decode_river3_payload(2, 0xFE, 0x15, b"") == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("src,cmd_set,cmd_id", [(3, 0xFE, 0x15), (2, 0x20, 2), (2, 0xFE, 0x16)])
|
||||
def test_unconfirmed_and_unrelated_messages_are_not_mapped(src, cmd_set, cmd_id):
|
||||
assert decode_river3_payload(src, cmd_set, cmd_id, display_payload()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
b"\x00", b"\x80", b"\x80" * 11, b"\x08" + b"\xff" * 10,
|
||||
varint(3 << 3 | 5) + b"\x00" * 3,
|
||||
varint(99 << 3 | 1) + b"\x00" * 7,
|
||||
varint(99 << 3 | 2) + varint(4) + b"abc",
|
||||
varint(99 << 3 | 3), varint(1 << 32),
|
||||
])
|
||||
def test_malformed_protobuf_is_rejected_atomically(payload):
|
||||
assert decode_river3_payload(2, 0xFE, 0x15, floating(3, 10) + payload) is None
|
||||
|
||||
|
||||
def test_unknown_fields_and_wrong_wire_types_do_not_become_measurements():
|
||||
payload = (
|
||||
integer(3, 99) # Total input must be a float, not a varint.
|
||||
+ integer(999, 123)
|
||||
+ varint(998 << 3 | 1) + bytes(8)
|
||||
+ varint(997 << 3 | 2) + varint(3) + b"abc"
|
||||
+ floating(4, 10) + floating(4, 20)
|
||||
)
|
||||
assert decode_river3_payload(2, 0xFE, 0x15, payload) == {"pow_out_sum_w": 20}
|
||||
|
||||
|
||||
def test_signed_temperatures_and_unsigned_time_validation():
|
||||
payload = integer(102, -5) + integer(259, -3) + integer(269, 1 << 32)
|
||||
assert decode_river3_payload(2, 0xFE, 0x15, payload) == {
|
||||
"cms_batt_temp": -5, "bms_max_cell_temp": -3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["R651", "R653", "R654", "R655"])
|
||||
def test_river3_snapshot_and_serialization(prefix):
|
||||
dev = device(prefix + "TEST00000001")
|
||||
dev._record(report(display_payload()))
|
||||
snap = dev.snapshot
|
||||
assert dev._pkt_version == 3
|
||||
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.88918)
|
||||
assert snap.bms_temperature_celcius.raw_values == {1: 29}
|
||||
assert snap.ef_input_power_watts.raw_value == 244
|
||||
assert snap.ef_output_power_watts.raw_value == 39
|
||||
assert snap.bms_charge_is_charging.raw_value is True
|
||||
assert snap.bms_charge_is_discharging.raw_value is False
|
||||
assert snap.ef_remaining_time_minutes.raw_value == 10
|
||||
assert snap.ef_charge_remaining_time_minutes.units == "min"
|
||||
assert snap.ef_charge_state.raw_value == 1
|
||||
serialized = {name: asdict(value) for name, value in snap.items()}
|
||||
assert dict(UPS.from_dict(serialized).items()) == dict(snap.items())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("serial", ["R601TEST00000001", "R631TEST00000001", "unknown"])
|
||||
def test_other_models_do_not_use_river3_decoder(serial):
|
||||
dev = device(serial)
|
||||
dev._record(report(display_payload()))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||
assert dev.snapshot.ef_input_power_watts is None
|
||||
|
||||
|
||||
def test_partial_updates_keep_missing_values_and_preserve_explicit_zero():
|
||||
dev = device()
|
||||
dev._record(report(floating(262, 0)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0
|
||||
assert dev.snapshot.ef_input_power_watts is None
|
||||
dev._record(report(floating(3, 44) + floating(4, 44)))
|
||||
assert dev.snapshot.bms_charge_is_charging is None # AC bypass is not charging.
|
||||
dev._record(report(floating(158, 0) + integer(102, -5)))
|
||||
assert dev.snapshot.bms_charge_is_charging.raw_value is False
|
||||
assert dev.snapshot.bms_charge_is_discharging.raw_value is False
|
||||
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: -5}
|
||||
assert dev.snapshot.ef_output_power_watts.raw_value == 44
|
||||
|
||||
|
||||
def test_cell_temperature_remains_preferred_across_partial_updates():
|
||||
dev = device()
|
||||
dev._record(report(integer(259, 29)))
|
||||
dev._record(report(integer(102, 26)))
|
||||
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: 29}
|
||||
dev._record(report(integer(259, 30)))
|
||||
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: 30}
|
||||
|
||||
|
||||
def test_remaining_time_tracks_battery_direction_and_missing_estimates():
|
||||
dev = device()
|
||||
dev._record(report(display_payload()))
|
||||
dev._record(report(floating(158, -64)))
|
||||
assert dev.snapshot.bms_charge_is_charging.raw_value is False
|
||||
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
|
||||
assert dev.snapshot.ef_remaining_time_minutes.raw_value == 184
|
||||
dev._record(report(integer(268, 0xFFFFFFFF)))
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
|
||||
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||
dev._record(report(integer(268, 0)))
|
||||
assert dev.snapshot.ef_remaining_time_minutes.raw_value == 0
|
||||
dev._record(report(floating(158, 0)))
|
||||
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||
|
||||
|
||||
def test_no_load_sample_matches_display_hours_without_changing_minute_units():
|
||||
# Synthetic message using observed measurements, without device identifiers.
|
||||
# The display showed 89% and 75–76 hours, not 75–76 minutes.
|
||||
dev = device()
|
||||
dev._record(report(b"".join([
|
||||
floating(262, 89), floating(3, 0), floating(4, 0),
|
||||
floating(158, -1), integer(259, 29), integer(268, 4556),
|
||||
integer(269, 4559), integer(282, 0),
|
||||
])))
|
||||
# A later configuration-only update must not erase these measurements.
|
||||
dev._record(report(integer(363, 1)))
|
||||
snap = dev.snapshot
|
||||
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.89)
|
||||
assert snap.bms_temperature_celcius.raw_values == {1: 29}
|
||||
assert snap.ef_input_power_watts.raw_value == 0
|
||||
assert snap.ef_output_power_watts.raw_value == 0
|
||||
assert snap.bms_charge_is_charging.raw_value is False
|
||||
assert snap.bms_charge_is_discharging.raw_value is True
|
||||
assert snap.ef_charge_state.raw_value == 0
|
||||
assert snap.ef_charge_remaining_time_minutes.raw_value == 4559
|
||||
assert snap.ef_discharge_remaining_time_minutes.raw_value == 4556
|
||||
assert snap.ef_remaining_time_minutes.raw_value == 4556
|
||||
assert snap.ef_remaining_time_minutes.units == "min"
|
||||
assert divmod(snap.ef_remaining_time_minutes.raw_value, 60) == (75, 56)
|
||||
|
||||
|
||||
def test_usb_load_sample_uses_battery_power_not_river2_state_codes():
|
||||
# Observed values encoded into a synthetic report. River 3 reported state 1
|
||||
# while discharging; River 2 uses state 1 for charging.
|
||||
dev = device()
|
||||
dev._record(report(b"".join([
|
||||
floating(262, 89), floating(3, 0), floating(4, 14),
|
||||
floating(11, -14.322), floating(158, -16), integer(259, 29),
|
||||
integer(268, 754), integer(269, 802), integer(281, 1), integer(282, 1),
|
||||
])))
|
||||
snap = dev.snapshot
|
||||
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.89)
|
||||
assert snap.ef_input_power_watts.raw_value == 0
|
||||
assert snap.ef_output_power_watts.raw_value == 14
|
||||
assert snap.bms_charge_is_charging.raw_value is False
|
||||
assert snap.bms_charge_is_discharging.raw_value is True
|
||||
assert snap.ef_charge_state.raw_value == 1
|
||||
assert snap.ef_remaining_time_minutes.raw_value == 754
|
||||
assert snap.ef_charge_remaining_time_minutes.raw_value == 802
|
||||
assert divmod(snap.ef_remaining_time_minutes.raw_value, 60) == (12, 34)
|
||||
|
||||
|
||||
def test_river2_time_convention_is_not_assumed_for_river3():
|
||||
dev = device()
|
||||
dev._record(report(floating(158, -1) + integer(268, 5999)))
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 5999
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_nonfinite_measurements_are_not_exported(invalid):
|
||||
dev = device()
|
||||
dev._record(report(b"".join(floating(field, invalid) for field in (262, 3, 4, 158))))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||
assert dev.snapshot.ef_input_power_watts is None
|
||||
assert dev.snapshot.ef_output_power_watts is None
|
||||
assert dev.snapshot.bms_charge_is_charging is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("soc", [-1, 101])
|
||||
def test_invalid_soc_and_negative_total_power_are_not_exported(soc):
|
||||
dev = device()
|
||||
dev._record(report(floating(262, soc) + floating(3, -1) + floating(4, -2)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||
assert dev.snapshot.ef_input_power_watts is None
|
||||
assert dev.snapshot.ef_output_power_watts is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [3, 0x13])
|
||||
def test_encrypted_xor_reports_survive_notification_fragmentation(version):
|
||||
pytest.importorskip("Crypto")
|
||||
enc = _Type7Encryption(bytes(range(16)), bytes(range(16, 32)))
|
||||
payload = display_payload(power=-64)
|
||||
key = 0x75
|
||||
pkt = EcoFlowPacket(
|
||||
2, 0x21, 0xFE, 0x15, bytes(b ^ key for b in payload),
|
||||
version=version, seq=bytes([key, 0, 0, 0]),
|
||||
)
|
||||
wire = _EncFrameHelper(enc).encode(pkt)
|
||||
|
||||
async def run():
|
||||
for split in range(1, len(wire)):
|
||||
dev = device()
|
||||
dev._state = _State.AUTHENTICATED
|
||||
dev._assembler = _EncFrameHelper(enc, xor_payload=dev._is_river3)
|
||||
await dev._on_data(bytearray(wire[:split]))
|
||||
assert dev.snapshot.ef_input_power_watts is None
|
||||
await dev._on_data(bytearray(wire[split:]))
|
||||
assert dev.snapshot.ef_input_power_watts.raw_value == 244
|
||||
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
|
||||
assert dev.snapshot._packets["ef_pkt_02_fe_15"].info == payload.hex()
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_river2_payload_is_not_xored():
|
||||
pytest.importorskip("Crypto")
|
||||
enc = _Type7Encryption(bytes(16), bytes(16))
|
||||
dev = device("R601TEST00000001")
|
||||
helper = _EncFrameHelper(enc, xor_payload=dev._is_river3)
|
||||
pkt = EcoFlowPacket(2, 0x21, 0x20, 2, b"plain", seq=b"\x75\x00\x00\x00", version=2)
|
||||
assert helper.decode(helper.encode(pkt))[0].payload == b"plain"
|
||||
|
||||
|
||||
def test_collection_waits_for_partial_measurements_not_unconfirmed_runtime(monkeypatch, capsys):
|
||||
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
|
||||
spec = importlib.util.spec_from_file_location("ecoflow_diagnostic_river3", path)
|
||||
script = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(script)
|
||||
dev = device()
|
||||
fragments = iter([floating(262, 50), display_payload()])
|
||||
|
||||
async def receive(_):
|
||||
dev._record(report(next(fragments)))
|
||||
|
||||
sleep = AsyncMock(side_effect=receive)
|
||||
monkeypatch.setattr(script.asyncio, "sleep", sleep)
|
||||
expected = script.MODEL_PROFILES["R651"]["expected_fields"]
|
||||
assert "ef_pkt_02_fe_16" not in expected
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=1))
|
||||
assert sleep.await_count == 2
|
||||
assert "complete (1 packet type(s))" in capsys.readouterr().out
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Schema-based diagnostic rendering with synthetic data only."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.ecoflow_proto import (
|
||||
decode_payload, decode_schema_protobuf, describe_protobuf_field,
|
||||
)
|
||||
from bmspy.ecoflow_river3 import decode_river3_payload
|
||||
from bmspy.ecoflow_river3_schema import ENUMS, MESSAGES, SCHEMA_COMMIT
|
||||
|
||||
|
||||
def varint(value):
|
||||
value &= (1 << 64) - 1
|
||||
result = bytearray()
|
||||
while value > 127:
|
||||
result.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
result.append(value)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def integer(number, value):
|
||||
return varint(number << 3) + varint(value)
|
||||
|
||||
|
||||
def blob(number, data):
|
||||
return varint(number << 3 | 2) + varint(len(data)) + data
|
||||
|
||||
|
||||
def floating(number, value):
|
||||
return varint(number << 3 | 5) + struct.pack('<f', value)
|
||||
|
||||
|
||||
def display(data):
|
||||
return '\n'.join(decode_payload(2, 0xFE, 0x15, data))
|
||||
|
||||
|
||||
def sample(number, kind):
|
||||
if kind in MESSAGES or kind == 'string':
|
||||
return blob(number, b'')
|
||||
if kind == 'float':
|
||||
return floating(number, 1.5)
|
||||
assert kind in ('bool', 'uint32', 'int32') or kind in ENUMS
|
||||
return integer(number, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('message,cmd_id,count', [
|
||||
('DisplayPropertyUpload', 0x15, 296), ('RuntimePropertyUpload', 0x16, 98),
|
||||
])
|
||||
def test_every_pinned_top_level_field_has_a_label(message, cmd_id, count):
|
||||
assert len(MESSAGES[message]) == count
|
||||
for number, (name, kind, repeated) in MESSAGES[message].items():
|
||||
text = '\n'.join(decode_payload(2, 0xFE, cmd_id, sample(number, kind)))
|
||||
assert name + ':' in text
|
||||
assert describe_protobuf_field(2, 0xFE, cmd_id, number) == name
|
||||
assert not re.search(r'\bfield\s+\d+:', text)
|
||||
assert not any(word in text for word in ('<invalid', '<malformed', '<wire type'))
|
||||
|
||||
|
||||
def test_error_records_have_named_children_and_preserve_empty_records():
|
||||
item = integer(1, 42) + integer(2, 1234)
|
||||
text = display(blob(141, blob(1, item) + blob(1, b'')))
|
||||
assert 'err_code_record_list: {' in text
|
||||
assert 'list_info: {' in text
|
||||
assert 'errcode: 42' in text
|
||||
assert 'errcode_timestamp: 1234' in text
|
||||
assert 'list_info: {}' in text
|
||||
assert text.count('errcode:') == 1 # Empty records do not become invented errors.
|
||||
assert not re.search(r'\bfield\s+\d+:', text)
|
||||
|
||||
|
||||
def test_statistics_have_named_categories_without_invented_energy_units():
|
||||
items = blob(1, integer(1, 2) + integer(2, 17))
|
||||
items += blob(1, integer(1, 4)) # Content absent, not an explicit zero.
|
||||
items += blob(1, b'')
|
||||
text = display(blob(463, items))
|
||||
assert 'display_statistics_sum:' in text
|
||||
assert 'statistics_object: STATISTICS_OBJECT_AC_OUT_ENERGY (2)' in text
|
||||
assert 'statistics_object: STATISTICS_OBJECT_TYPEC_OUT_ENERGY (4)' in text
|
||||
assert 'statistics_content: 17' in text
|
||||
assert text.count('statistics_content:') == 1
|
||||
assert 'list_info: {}' in text
|
||||
assert ' Wh' not in text
|
||||
assert not re.search(r'\bfield\s+\d+:', text)
|
||||
|
||||
|
||||
def test_strings_are_not_guessed_as_nested_messages():
|
||||
text = display(blob(88, b'') + blob(433, b'TEST-ACCESSORY') + blob(91, b''))
|
||||
assert "plug_in_info_dcp2_sn: ''" in text
|
||||
assert "plug_in_info_dcp_sn: 'TEST-ACCESSORY'" in text
|
||||
assert 'plug_in_info_dcp2_resv: {}' in text
|
||||
invalid = display(blob(433, b'\xff'))
|
||||
assert 'plug_in_info_dcp_sn: <invalid string:' in invalid
|
||||
assert 'raw:' in invalid
|
||||
|
||||
|
||||
def test_packed_and_unpacked_repeated_integers():
|
||||
values = [0, 127, 128, 0xFFFFFFFF]
|
||||
packed = display(blob(432, blob(1, b''.join(varint(n) for n in values))))
|
||||
assert 'resv_info: [0, 127, 128, 4294967295]' in packed
|
||||
unpacked = display(blob(432, b''.join(integer(1, n) for n in values)))
|
||||
for value in values:
|
||||
assert f'resv_info: {value}' in unpacked
|
||||
assert 'resv_info: []' in display(blob(432, blob(1, b'')))
|
||||
assert 'resv_info: <invalid uint32:' in display(blob(432, blob(1, b'\x80')))
|
||||
|
||||
|
||||
def test_signed_boolean_enum_and_unknown_enum_values():
|
||||
assert 'bms_min_cell_temp: -7 °C' in display(integer(258, -7))
|
||||
assert 'xboost_en: true' in display(integer(25, 1))
|
||||
assert 'xboost_en: false' in display(integer(25, 0))
|
||||
assert 'statistics_object: 999 (unknown STATISTICS_OBJECT)' in display(blob(463, blob(1, integer(1, 999))))
|
||||
|
||||
|
||||
def test_known_field_with_wrong_encoding_retains_label_and_raw_bytes():
|
||||
text = display(integer(3, 10))
|
||||
assert 'pow_in_sum_w: <wire type 0, expected 5, raw:' in text
|
||||
assert '10 W' not in text
|
||||
assert 'pow_get_bms: <wire type 2, expected 5' in display(blob(158, b'bad'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [b'\x00', b'\x80', b'\x0a\x05\x01', b'\x0d\x00', b'\x09\x00'])
|
||||
def test_malformed_reports_are_not_normal_measurements(bad):
|
||||
text = display(floating(262, 89) + bad)
|
||||
assert '<malformed DisplayPropertyUpload:' in text
|
||||
assert 'raw:' in text
|
||||
assert 'cms_batt_soc: 89' not in text
|
||||
assert decode_river3_payload(2, 0xFE, 0x15, floating(262, 89) + bad) is None
|
||||
|
||||
|
||||
def test_unknown_future_fields_remain_numeric_and_raw():
|
||||
text = display(integer(999, 7) + blob(1000, b'opaque'))
|
||||
assert 'field 999: 7' in text
|
||||
assert 'field 1000:' in text
|
||||
assert b'opaque'.hex() in text
|
||||
assert 'unknown' in text
|
||||
assert decode_payload(0x35, 0x35, 0x20, bytes(8)) is None
|
||||
|
||||
|
||||
def test_runtime_routing_stays_explicitly_unconfirmed():
|
||||
text = '\n'.join(decode_payload(2, 0xFE, 0x16, floating(244, 19.2)))
|
||||
assert 'routing unconfirmed' in text
|
||||
assert 'bms_batt_vol: 19.200 V' in text
|
||||
assert decode_river3_payload(2, 0xFE, 0x16, floating(244, 19.2)) is None
|
||||
|
||||
|
||||
def test_schema_nesting_has_a_depth_limit(monkeypatch):
|
||||
monkeypatch.setitem(MESSAGES, 'SyntheticRecursive', {1: ('child', 'SyntheticRecursive', False)})
|
||||
payload = b''
|
||||
for _ in range(30):
|
||||
payload = blob(1, payload)
|
||||
text = '\n'.join(decode_schema_protobuf(payload, 'SyntheticRecursive'))
|
||||
assert 'maximum nesting depth reached' in text
|
||||
assert 'raw:' in text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generator():
|
||||
path = Path(__file__).resolve().parents[1] / 'tools/generate_ecoflow_river3_schema.py'
|
||||
spec = importlib.util.spec_from_file_location('schema_generator', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_generator_is_deterministic_and_pinned(generator):
|
||||
path = Path(__file__).resolve().parents[1] / 'bmspy/ecoflow_river3_schema.py'
|
||||
assert re.fullmatch('[a-f0-9]{40}', SCHEMA_COMMIT)
|
||||
assert generator.generate(MESSAGES, ENUMS, SCHEMA_COMMIT) == path.read_text()
|
||||
|
||||
|
||||
def test_generator_keeps_reachable_types_and_ignores_unrelated_nested_types(generator):
|
||||
schema = '''
|
||||
message DisplayPropertyUpload { optional Item values = 1; }
|
||||
message RuntimePropertyUpload { repeated uint32 raw = 2; }
|
||||
message Item { optional Kind kind = 1; }
|
||||
enum Kind { NONE = 0; ACTIVE = 1; }
|
||||
message Unrelated { message Item { optional uint32 unrelated = 1; } }
|
||||
'''
|
||||
messages, enums = generator.parse_schema(schema)
|
||||
assert messages['Item'] == {1: ('kind', 'Kind', False)}
|
||||
assert messages['RuntimePropertyUpload'][2] == ('raw', 'uint32', True)
|
||||
assert enums['Kind'][1] == 'ACTIVE'
|
||||
assert 'Unrelated' not in messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
'optional Missing value = 1;',
|
||||
'optional uint32 a = 1; optional uint32 b = 1;',
|
||||
'map<string, int32> unsupported = 1;',
|
||||
'message Nested {} optional Nested value = 1;',
|
||||
])
|
||||
def test_generator_rejects_unsupported_or_incomplete_reachable_types(generator, body):
|
||||
with pytest.raises(ValueError):
|
||||
generator.parse_schema('message DisplayPropertyUpload {' + body + '} message RuntimePropertyUpload {}')
|
||||
Reference in New Issue
Block a user