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
+6 -2
View File
@@ -94,8 +94,12 @@ Omit `--ups` for all devices. Add `--ups river2` to select one. `--ups all` mean
device named `all`, not all devices. To include a serial BMS, add a server argument device named `all`, not all devices. To include a serial BMS, add a server argument
such as `-d network:/dev/ttyUSB0`. such as `-d network:/dev/ttyUSB0`.
The server caches Bluetooth data for 60 seconds. Failed reads wait 60 seconds The server reads each device in its own background thread, so a slow or offline
before retrying. Unavailable devices are omitted from the response. device does not delay the other devices or the clients. It reads one Bluetooth
device at a time and waits 60 seconds after each Bluetooth read, including a
failed one. It reads a serial BMS every 5 seconds and retries a failed serial
read after 1 second. Unavailable devices are omitted from the response. Only the
first request after startup waits, until each device completes one read attempt.
#### Diagnostics #### Diagnostics
+68 -14
View File
@@ -13,8 +13,9 @@ import signal
import json import json
import re import re
import struct import struct
import threading
import serial import serial
from dataclasses import asdict as dataclass_asdict, dataclass from dataclasses import asdict as dataclass_asdict, dataclass, field
from types import FrameType from types import FrameType
from typing import Any, NoReturn from typing import Any, NoReturn
@@ -34,6 +35,18 @@ class DeviceState:
data: UPS | None = None data: UPS | None = None
timestamp: float = 0.0 # Last collection attempt timestamp: float = 0.0 # Last collection attempt
ecoflow: EcoFlowBTConfig | None = None # EcoFlow BT config ecoflow: EcoFlowBTConfig | None = None # EcoFlow BT config
# Set after the first collection attempt, successful or not.
polled: threading.Event = field(default_factory=threading.Event, repr=False, compare=False)
SERIAL_POLL_SECONDS = 5
SERIAL_RETRY_SECONDS = 1
SERIAL_STALE_SECONDS = 30
BLUETOOTH_POLL_SECONDS = 60
FIRST_POLL_TIMEOUT = 120
# One adapter: collect from one Bluetooth device at a time.
bluetooth_lock = threading.Lock()
# Expected kernel log output when the USB-serial adapter is plugged in: # Expected kernel log output when the USB-serial adapter is plugged in:
# usb 1-1.4: new full-speed USB device number 4 using xhci_hcd # usb 1-1.4: new full-speed USB device number 4 using xhci_hcd
@@ -172,23 +185,57 @@ def collect_device_data(device: DeviceState, debug: int = 0) -> UPS | None:
if device.ser is not None: if device.ser is not None:
return collect_data(device.ser, debug) or None return collect_data(device.ser, debug) or None
if device.ecoflow is not None: if device.ecoflow is not None:
with bluetooth_lock:
return ecoflow_collect_data(device.ecoflow, debug) or None return ecoflow_collect_data(device.ecoflow, debug) or None
return None return None
def get_device_data(device: DeviceState, debug: int = 0) -> UPS | None: def poll_device(device: DeviceState, stop: threading.Event, debug: int = 0) -> None:
"""Return cached data, refreshing it when the backend's interval expires.""" """Keep *device.data* current until *stop* is set.
min_age = 60 if device.ecoflow is not None else 5
if device.timestamp <= time.time() - min_age: Each device has its own thread, so a slow or offline device delays neither
device.data = collect_device_data(device, debug) the clients nor the other devices. A collection replaces the snapshot and
if device.ecoflow is None: never changes one that clients can already read, so no lock is needed.
# Preserve serial retries. BLE already retries within collect_data; """
# retrying it forever here would block all clients when it is offline. last_success = 0.0
while device.data is None: while not stop.is_set():
time.sleep(1) try:
device.data = collect_device_data(device, debug) data = collect_device_data(device, debug)
except Exception as e:
# A failed read must not end polling for this device.
debugger("server: collection error: {}".format(e))
data = None
device.timestamp = time.time() device.timestamp = time.time()
return device.data if data is not None:
device.data = data
last_success = device.timestamp
delay = BLUETOOTH_POLL_SECONDS if device.ecoflow is not None else SERIAL_POLL_SECONDS
elif device.ecoflow is not None:
# BLE already retries within collect_data. Do not serve stale data.
device.data = None
delay = BLUETOOTH_POLL_SECONDS
else:
# Serial reads fail briefly at times. Retry soon, and keep the last
# snapshot for a short time so that one failed read stays invisible.
if device.timestamp - last_success > SERIAL_STALE_SECONDS:
device.data = None
delay = SERIAL_RETRY_SECONDS
device.polled.set()
stop.wait(delay)
def start_polling(
ups_devices: dict[str, DeviceState], stop: threading.Event, debug: int = 0,
) -> list[threading.Thread]:
"""Start one polling thread per device."""
threads = []
for name, device in ups_devices.items():
thread = threading.Thread(
target=poll_device, args=(device, stop, debug), name="poll-" + name, daemon=True,
)
thread.start()
threads.append(thread)
return threads
def main(): def main():
@@ -342,6 +389,10 @@ def main():
sock.listen(1) sock.listen(1)
stop_polling = threading.Event()
atexit.register(stop_polling.set)
start_polling(ups_devices, stop_polling, debug)
while True: while True:
connection = None connection = None
client_address = None client_address = None
@@ -398,7 +449,10 @@ def main():
name, device.timestamp, time.time() name, device.timestamp, time.time()
) )
) )
data = get_device_data(device, debug) # Wait for the first collection attempt only, so that a
# client at startup sees every device that can answer.
device.polled.wait(FIRST_POLL_TIMEOUT)
data = device.data
if data is not None: if data is not None:
result[name] = data result[name] = data
+18 -1
View File
@@ -11,7 +11,17 @@ import serial
from bmspy.classes import BMSScalarField, BMSInfoField, UPS from bmspy.classes import BMSScalarField, BMSInfoField, UPS
from bmspy.jbd_bms import JBDBMS 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.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.isdir", return_value=True), \
patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.path.exists", return_value=False), \
@@ -525,6 +536,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \ patch("bmspy.server.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.isdir", return_value=True), \
patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.path.exists", return_value=False), \
@@ -576,6 +588,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \ patch("bmspy.server.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.path.exists", return_value=False), \
patch("bmspy.server.os.path.isdir", 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.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.isdir", return_value=True), \
patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.path.exists", return_value=False), \
@@ -777,6 +791,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \ patch("bmspy.server.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=False), \ patch("bmspy.server.os.path.isdir", return_value=False), \
patch("bmspy.server.os.makedirs"), \ patch("bmspy.server.os.makedirs"), \
@@ -838,6 +853,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \ patch("bmspy.server.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=JBDBMS()), \ patch("bmspy.server.collect_data", return_value=JBDBMS()), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.isdir", return_value=True), \
patch("bmspy.server.os.path.exists", return_value=False), \ patch("bmspy.server.os.path.exists", return_value=False), \
@@ -901,6 +917,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \ patch("bmspy.server.signal.signal"), \
patch("bmspy.server.initialise_serial", return_value=MagicMock()), \ patch("bmspy.server.initialise_serial", return_value=MagicMock()), \
patch("bmspy.server.collect_data", return_value=fake_bms), \ patch("bmspy.server.collect_data", return_value=fake_bms), \
patch("bmspy.server.start_polling", _poll_once), \
patch("bmspy.server.time.sleep"), \ patch("bmspy.server.time.sleep"), \
patch("bmspy.server.os.path.isdir", return_value=True), \ patch("bmspy.server.os.path.isdir", return_value=True), \
patch("bmspy.server.os.path.exists", return_value=False), \ 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.""" """Server routing and caching tests with synthetic device identifiers."""
import threading
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
@@ -74,46 +75,100 @@ def test_collection_dispatches_to_the_selected_backend(monkeypatch):
assert server.collect_device_data(server.DeviceState()) is None 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)]) @pytest.mark.parametrize("bluetooth,interval", [(False, 5), (True, 60)])
def test_cache_interval_depends_on_backend(monkeypatch, bluetooth, interval): def test_poll_interval_depends_on_backend(monkeypatch, bluetooth, interval):
old, fresh = JBDBMS(), JBDBMS() fresh = JBDBMS()
config = initialise_device(ADDRESS, SERIAL, "test-user") if bluetooth else None 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) read = Mock(return_value=fresh)
monkeypatch.setattr(server, "collect_device_data", read) monkeypatch.setattr(server, "collect_device_data", read)
monkeypatch.setattr(server.time, "time", lambda: 100 + interval - 1) monkeypatch.setattr(server.time, "time", lambda: 100)
assert server.get_device_data(state) is old stop = StopAfter(1)
read.assert_not_called() server.poll_device(state, stop, 2)
monkeypatch.setattr(server.time, "time", lambda: 100 + interval) assert state.data is fresh
assert server.get_device_data(state) is fresh assert state.timestamp == 100
read.assert_called_once_with(state, 0) 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( state = server.DeviceState(
data=JBDBMS(), timestamp=1, ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"), data=JBDBMS(), timestamp=1, ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"),
) )
read = Mock(return_value=None) monkeypatch.setattr(server, "collect_device_data", Mock(return_value=None))
monkeypatch.setattr(server, "collect_device_data", read)
monkeypatch.setattr(server.time, "time", lambda: 100) 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 assert state.timestamp == 100
# A failed poll also has a backoff, rather than reconnecting on every GET. # A client must not wait for a device that is known to be offline.
assert server.get_device_data(state) is None assert state.polled.is_set()
read.assert_called_once_with(state, 0) assert stop.waits == [60]
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): def test_failed_serial_read_retries_soon_and_hides_a_short_failure(monkeypatch):
data = JBDBMS() data = JBDBMS()
state = server.DeviceState(ser=Mock()) state = server.DeviceState(ser=Mock())
read = Mock(side_effect=[None, data]) monkeypatch.setattr(server, "collect_device_data", Mock(side_effect=[data, None, None]))
sleep = Mock() 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, "collect_device_data", read)
monkeypatch.setattr(server.time, "time", lambda: 100) stop = threading.Event()
monkeypatch.setattr(server.time, "sleep", sleep) threads = server.start_polling({"offline": offline, "working": working}, stop)
assert server.get_device_data(state) is data try:
assert read.call_count == 2 assert offline_started.wait(5)
sleep.assert_called_once_with(1) 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)