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:
tim
2026-10-10 09:03:34 +02:00
parent d86d59c0f7
commit 953f8a6482
4 changed files with 175 additions and 45 deletions
+18 -1
View File
@@ -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), \
+82 -27
View File
@@ -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)