Integrate EcoFlow Bluetooth devices into the server
Serial paths and collector interfaces remain compatible. Bluetooth polling uses a 60-second cache and backoff. Offline Bluetooth devices no longer retry forever inside a client request. Invalid device specifications do not echo account IDs.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Server routing and caching tests with synthetic device identifiers."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy import server
|
||||
from bmspy.classes import BMSScalarField
|
||||
from bmspy.jbd_bms import JBDBMS
|
||||
from bmspy.ecoflow_bt import initialise_device
|
||||
|
||||
ADDRESS = "02:00:00:00:00:01"
|
||||
SERIAL = "R601TEST00000001"
|
||||
SPEC = f"{ADDRESS}/{SERIAL}/test-user"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec,name,path", [
|
||||
(SPEC, SERIAL, SPEC),
|
||||
(f"battery@{SPEC}", "battery", SPEC),
|
||||
("/tmp/serial-device", "serial-device", "/tmp/serial-device"),
|
||||
("ports/ttyUSB0", "ttyUSB0", "ports/ttyUSB0"),
|
||||
("office:ports/ttyUSB0", "office", "ports/ttyUSB0"),
|
||||
("office:/tmp/serial-device", "office", "/tmp/serial-device"),
|
||||
("/tmp/serial@device", "serial@device", "/tmp/serial@device"),
|
||||
])
|
||||
def test_parse_preserves_serial_paths_and_bluetooth_names(spec, name, path):
|
||||
assert server.parse_device(spec) == (name, path)
|
||||
assert server.is_ecoflow_device(path) is (path == SPEC)
|
||||
|
||||
|
||||
def test_parse_bluetooth_spec():
|
||||
assert server.parse_ecoflow_spec(SPEC) == (ADDRESS, SERIAL, "test-user")
|
||||
assert server.parse_ecoflow_spec("02:00:00:aa:bb:01 / SERIAL / user") == (
|
||||
"02:00:00:AA:BB:01", "SERIAL", "user",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", [
|
||||
ADDRESS, f"{ADDRESS}/{SERIAL}", f"{ADDRESS}//test-user", f"{ADDRESS}/{SERIAL}/",
|
||||
f"{SPEC}/extra", "GG:00:00:00:00:01/SERIAL/test-user",
|
||||
])
|
||||
def test_invalid_spec_does_not_echo_account_details(spec):
|
||||
assert server.is_ecoflow_device(spec)
|
||||
with pytest.raises(ValueError) as error:
|
||||
server.parse_ecoflow_spec(spec)
|
||||
assert str(error.value) == "EcoFlow BT device spec must be ADDRESS/SERIAL/USERID"
|
||||
|
||||
|
||||
def test_existing_positional_device_state_arguments_remain_valid():
|
||||
port, data = Mock(), JBDBMS()
|
||||
state = server.DeviceState(port, data, 123.0)
|
||||
assert state.ser is port
|
||||
assert state.data is data
|
||||
assert state.timestamp == 123.0
|
||||
assert state.ecoflow is None
|
||||
|
||||
|
||||
def test_collection_dispatches_to_the_selected_backend(monkeypatch):
|
||||
serial_data, bluetooth_data = JBDBMS(), JBDBMS()
|
||||
for data in (serial_data, bluetooth_data):
|
||||
data.bms_capacity_charge_ratio = BMSScalarField(
|
||||
help="Battery Charge", raw_value=0.5, value="0.5",
|
||||
)
|
||||
serial_read = Mock(return_value=serial_data)
|
||||
bluetooth_read = Mock(return_value=bluetooth_data)
|
||||
monkeypatch.setattr(server, "collect_data", serial_read)
|
||||
monkeypatch.setattr(server, "ecoflow_collect_data", bluetooth_read)
|
||||
port = Mock()
|
||||
config = initialise_device(ADDRESS, SERIAL, "test-user")
|
||||
assert server.collect_device_data(server.DeviceState(ser=port), 2) is serial_data
|
||||
serial_read.assert_called_once_with(port, 2)
|
||||
assert server.collect_device_data(server.DeviceState(ecoflow=config), 3) is bluetooth_data
|
||||
bluetooth_read.assert_called_once_with(config, 3)
|
||||
assert server.collect_device_data(server.DeviceState()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bluetooth,interval", [(False, 5), (True, 60)])
|
||||
def test_cache_interval_depends_on_backend(monkeypatch, bluetooth, interval):
|
||||
old, fresh = JBDBMS(), JBDBMS()
|
||||
config = initialise_device(ADDRESS, SERIAL, "test-user") if bluetooth else None
|
||||
state = server.DeviceState(data=old, timestamp=100, ecoflow=config)
|
||||
read = Mock(return_value=fresh)
|
||||
monkeypatch.setattr(server, "collect_device_data", read)
|
||||
monkeypatch.setattr(server.time, "time", lambda: 100 + interval - 1)
|
||||
assert server.get_device_data(state) is old
|
||||
read.assert_not_called()
|
||||
monkeypatch.setattr(server.time, "time", lambda: 100 + interval)
|
||||
assert server.get_device_data(state) is fresh
|
||||
read.assert_called_once_with(state, 0)
|
||||
|
||||
|
||||
def test_offline_bluetooth_does_not_retry_forever_or_serve_stale_data(monkeypatch):
|
||||
state = server.DeviceState(
|
||||
data=JBDBMS(), timestamp=1, ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"),
|
||||
)
|
||||
read = Mock(return_value=None)
|
||||
monkeypatch.setattr(server, "collect_device_data", read)
|
||||
monkeypatch.setattr(server.time, "time", lambda: 100)
|
||||
assert server.get_device_data(state) is None
|
||||
assert state.timestamp == 100
|
||||
# A failed poll also has a backoff, rather than reconnecting on every GET.
|
||||
assert server.get_device_data(state) is None
|
||||
read.assert_called_once_with(state, 0)
|
||||
monkeypatch.setattr(server.time, "time", lambda: 160)
|
||||
assert server.get_device_data(state) is None
|
||||
assert read.call_count == 2
|
||||
|
||||
|
||||
def test_serial_collection_still_retries(monkeypatch):
|
||||
data = JBDBMS()
|
||||
state = server.DeviceState(ser=Mock())
|
||||
read = Mock(side_effect=[None, data])
|
||||
sleep = Mock()
|
||||
monkeypatch.setattr(server, "collect_device_data", read)
|
||||
monkeypatch.setattr(server.time, "time", lambda: 100)
|
||||
monkeypatch.setattr(server.time, "sleep", sleep)
|
||||
assert server.get_device_data(state) is data
|
||||
assert read.call_count == 2
|
||||
sleep.assert_called_once_with(1)
|
||||
Reference in New Issue
Block a user