Read each device in a background thread
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.
This commit is contained in:
+18
-1
@@ -11,7 +11,17 @@ import serial
|
||||
|
||||
from bmspy.classes import BMSScalarField, BMSInfoField, UPS
|
||||
from bmspy.jbd_bms import JBDBMS
|
||||
from bmspy.server import DeviceState, parse_device, read_request, send_response
|
||||
from bmspy.server import (
|
||||
DeviceState, collect_device_data, parse_device, read_request, send_response,
|
||||
)
|
||||
|
||||
|
||||
def _poll_once(devices, stop, debug=0):
|
||||
"""Collect once in the server thread. main() stops its polling threads only
|
||||
at process exit, so real ones would outlive a test and call later mocks."""
|
||||
for device in devices.values():
|
||||
device.data = collect_device_data(device, debug)
|
||||
device.polled.set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -340,6 +350,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=fake_bms), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=True), \
|
||||
patch("bmspy.server.os.path.exists", return_value=False), \
|
||||
@@ -525,6 +536,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=fake_bms), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=True), \
|
||||
patch("bmspy.server.os.path.exists", return_value=False), \
|
||||
@@ -576,6 +588,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=fake_bms), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.exists", return_value=False), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=False), \
|
||||
@@ -686,6 +699,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=fake_bms), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=True), \
|
||||
patch("bmspy.server.os.path.exists", return_value=False), \
|
||||
@@ -777,6 +791,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=fake_bms), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=False), \
|
||||
patch("bmspy.server.os.makedirs"), \
|
||||
@@ -838,6 +853,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=JBDBMS()), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=True), \
|
||||
patch("bmspy.server.os.path.exists", return_value=False), \
|
||||
@@ -901,6 +917,7 @@ class TestServerMain:
|
||||
patch("bmspy.server.signal.signal"), \
|
||||
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
|
||||
patch("bmspy.server.collect_data", return_value=fake_bms), \
|
||||
patch("bmspy.server.start_polling", _poll_once), \
|
||||
patch("bmspy.server.time.sleep"), \
|
||||
patch("bmspy.server.os.path.isdir", return_value=True), \
|
||||
patch("bmspy.server.os.path.exists", return_value=False), \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Server routing and caching tests with synthetic device identifiers."""
|
||||
|
||||
import threading
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -74,46 +75,100 @@ def test_collection_dispatches_to_the_selected_backend(monkeypatch):
|
||||
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_cache_interval_depends_on_backend(monkeypatch, bluetooth, interval):
|
||||
old, fresh = JBDBMS(), JBDBMS()
|
||||
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(data=old, timestamp=100, ecoflow=config)
|
||||
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 + 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)
|
||||
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_forever_or_serve_stale_data(monkeypatch):
|
||||
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"),
|
||||
)
|
||||
read = Mock(return_value=None)
|
||||
monkeypatch.setattr(server, "collect_device_data", read)
|
||||
monkeypatch.setattr(server, "collect_device_data", Mock(return_value=None))
|
||||
monkeypatch.setattr(server.time, "time", lambda: 100)
|
||||
assert server.get_device_data(state) is None
|
||||
stop = StopAfter(1)
|
||||
server.poll_device(state, stop)
|
||||
assert state.data 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
|
||||
# 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_serial_collection_still_retries(monkeypatch):
|
||||
def test_failed_serial_read_retries_soon_and_hides_a_short_failure(monkeypatch):
|
||||
data = JBDBMS()
|
||||
state = server.DeviceState(ser=Mock())
|
||||
read = Mock(side_effect=[None, data])
|
||||
sleep = 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)
|
||||
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)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user