A failed JBD read retried forever inside a client request and blocked every other device and client. The server now starts one polling thread for each device, and a request returns the last snapshot. Only the first request after startup waits, until each device completes one read attempt. One lock makes sure that only one Bluetooth collection runs at a time. A serial BMS retries a failed read after 1 second and keeps its last snapshot for 30 seconds. An error in a collection does not end the polling of that device.
175 lines
6.5 KiB
Python
175 lines
6.5 KiB
Python
"""Server routing and caching tests with synthetic device identifiers."""
|
|
|
|
import threading
|
|
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
|
|
|
|
|
|
class StopAfter:
|
|
"""Stand-in for the stop event: record each wait and stop after *loops* polls."""
|
|
|
|
def __init__(self, loops, observe=lambda: None):
|
|
self.loops, self.observe = loops, observe
|
|
self.waits, self.seen = [], []
|
|
|
|
def is_set(self):
|
|
return len(self.waits) >= self.loops
|
|
|
|
def wait(self, delay):
|
|
self.waits.append(delay)
|
|
self.seen.append(self.observe())
|
|
|
|
|
|
@pytest.mark.parametrize("bluetooth,interval", [(False, 5), (True, 60)])
|
|
def test_poll_interval_depends_on_backend(monkeypatch, bluetooth, interval):
|
|
fresh = JBDBMS()
|
|
config = initialise_device(ADDRESS, SERIAL, "test-user") if bluetooth else None
|
|
state = server.DeviceState(ser=None if bluetooth else Mock(), ecoflow=config)
|
|
read = Mock(return_value=fresh)
|
|
monkeypatch.setattr(server, "collect_device_data", read)
|
|
monkeypatch.setattr(server.time, "time", lambda: 100)
|
|
stop = StopAfter(1)
|
|
server.poll_device(state, stop, 2)
|
|
assert state.data is fresh
|
|
assert state.timestamp == 100
|
|
assert state.polled.is_set()
|
|
assert stop.waits == [interval]
|
|
read.assert_called_once_with(state, 2)
|
|
|
|
|
|
def test_offline_bluetooth_does_not_retry_at_once_or_serve_stale_data(monkeypatch):
|
|
state = server.DeviceState(
|
|
data=JBDBMS(), timestamp=1, ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"),
|
|
)
|
|
monkeypatch.setattr(server, "collect_device_data", Mock(return_value=None))
|
|
monkeypatch.setattr(server.time, "time", lambda: 100)
|
|
stop = StopAfter(1)
|
|
server.poll_device(state, stop)
|
|
assert state.data is None
|
|
assert state.timestamp == 100
|
|
# A client must not wait for a device that is known to be offline.
|
|
assert state.polled.is_set()
|
|
assert stop.waits == [60]
|
|
|
|
|
|
def test_failed_serial_read_retries_soon_and_hides_a_short_failure(monkeypatch):
|
|
data = JBDBMS()
|
|
state = server.DeviceState(ser=Mock())
|
|
monkeypatch.setattr(server, "collect_device_data", Mock(side_effect=[data, None, None]))
|
|
clock = iter([100, 110, 131])
|
|
monkeypatch.setattr(server.time, "time", lambda: next(clock))
|
|
stop = StopAfter(3, observe=lambda: state.data)
|
|
server.poll_device(state, stop)
|
|
# The last snapshot stays for 30 seconds, then the device reads as absent.
|
|
assert stop.seen == [data, data, None]
|
|
assert stop.waits == [5, 1, 1]
|
|
|
|
|
|
def test_collection_error_does_not_end_polling(monkeypatch, capsys):
|
|
data = JBDBMS()
|
|
state = server.DeviceState(ser=Mock())
|
|
monkeypatch.setattr(server, "collect_device_data", Mock(side_effect=[RuntimeError("boom"), data]))
|
|
stop = StopAfter(2)
|
|
server.poll_device(state, stop)
|
|
assert state.data is data
|
|
assert stop.waits == [1, 5]
|
|
assert "collection error: boom" in capsys.readouterr().out
|
|
|
|
|
|
def test_polling_threads_do_not_wait_for_each_other_and_stop(monkeypatch):
|
|
data = JBDBMS()
|
|
offline_started, release = threading.Event(), threading.Event()
|
|
working, offline = server.DeviceState(ser=Mock()), server.DeviceState(ser=Mock())
|
|
|
|
def read(device, debug=0):
|
|
if device is offline:
|
|
offline_started.set()
|
|
release.wait(5)
|
|
return None
|
|
return data
|
|
|
|
monkeypatch.setattr(server, "collect_device_data", read)
|
|
stop = threading.Event()
|
|
threads = server.start_polling({"offline": offline, "working": working}, stop)
|
|
try:
|
|
assert offline_started.wait(5)
|
|
assert working.polled.wait(5)
|
|
assert working.data is data
|
|
assert not offline.polled.is_set()
|
|
finally:
|
|
stop.set()
|
|
release.set()
|
|
for thread in threads:
|
|
thread.join(5)
|
|
assert not any(thread.is_alive() for thread in threads)
|