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
+69 -15
View File
@@ -13,8 +13,9 @@ import signal
import json
import re
import struct
import threading
import serial
from dataclasses import asdict as dataclass_asdict, dataclass
from dataclasses import asdict as dataclass_asdict, dataclass, field
from types import FrameType
from typing import Any, NoReturn
@@ -34,6 +35,18 @@ class DeviceState:
data: UPS | None = None
timestamp: float = 0.0 # Last collection attempt
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:
# 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:
return collect_data(device.ser, debug) or None
if device.ecoflow is not None:
return ecoflow_collect_data(device.ecoflow, debug) or None
with bluetooth_lock:
return ecoflow_collect_data(device.ecoflow, debug) or None
return None
def get_device_data(device: DeviceState, debug: int = 0) -> UPS | None:
"""Return cached data, refreshing it when the backend's interval expires."""
min_age = 60 if device.ecoflow is not None else 5
if device.timestamp <= time.time() - min_age:
device.data = collect_device_data(device, debug)
if device.ecoflow is None:
# Preserve serial retries. BLE already retries within collect_data;
# retrying it forever here would block all clients when it is offline.
while device.data is None:
time.sleep(1)
device.data = collect_device_data(device, debug)
def poll_device(device: DeviceState, stop: threading.Event, debug: int = 0) -> None:
"""Keep *device.data* current until *stop* is set.
Each device has its own thread, so a slow or offline device delays neither
the clients nor the other devices. A collection replaces the snapshot and
never changes one that clients can already read, so no lock is needed.
"""
last_success = 0.0
while not stop.is_set():
try:
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()
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():
@@ -342,6 +389,10 @@ def main():
sock.listen(1)
stop_polling = threading.Event()
atexit.register(stop_polling.set)
start_polling(ups_devices, stop_polling, debug)
while True:
connection = None
client_address = None
@@ -398,7 +449,10 @@ def main():
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:
result[name] = data