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:
+69
-15
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user