Disconnect from Bluetooth devices before the server stops

The polling threads are daemon threads, so a stop ended the server in the middle of a Bluetooth read. The read got no disconnect, and the adapter kept an open connection or a scan in progress.

The server now sets the stop event at exit, closes its socket, and waits at most 20 seconds for the polling threads. A Bluetooth read that sees the stop event cancels its current step, disconnects, and returns. A read that waits for the adapter does not connect after a stop.
This commit is contained in:
tim
2026-10-10 11:47:16 +02:00
parent dc1fb95ada
commit 0c179d29e5
6 changed files with 272 additions and 24 deletions
+52 -3
View File
@@ -1,6 +1,7 @@
"""Server routing and caching tests with synthetic device identifiers."""
import threading
import time
from unittest.mock import Mock
import pytest
@@ -71,7 +72,7 @@ def test_collection_dispatches_to_the_selected_backend(monkeypatch):
assert server.collect_device_data(server.DeviceState(ser=port), 2) is serial_data
serial_read.assert_called_once_with(port, 2)
assert server.collect_device_data(server.DeviceState(ecoflow=config), 3) is bluetooth_data
bluetooth_read.assert_called_once_with(config, 3)
bluetooth_read.assert_called_once_with(config, 3, None)
assert server.collect_device_data(server.DeviceState()) is None
@@ -104,7 +105,7 @@ def test_poll_interval_depends_on_backend(monkeypatch, bluetooth, interval):
assert state.timestamp == 100
assert state.polled.is_set()
assert stop.waits == [interval]
read.assert_called_once_with(state, 2)
read.assert_called_once_with(state, 2, stop)
def test_offline_bluetooth_does_not_retry_at_once_or_serve_stale_data(monkeypatch):
@@ -151,7 +152,7 @@ def test_polling_threads_do_not_wait_for_each_other_and_stop(monkeypatch):
offline_started, release = threading.Event(), threading.Event()
working, offline = server.DeviceState(ser=Mock()), server.DeviceState(ser=Mock())
def read(device, debug=0):
def read(device, debug=0, stop=None):
if device is offline:
offline_started.set()
release.wait(5)
@@ -172,3 +173,51 @@ def test_polling_threads_do_not_wait_for_each_other_and_stop(monkeypatch):
for thread in threads:
thread.join(5)
assert not any(thread.is_alive() for thread in threads)
def test_stop_polling_waits_for_the_bluetooth_disconnect(monkeypatch):
events = []
def bluetooth_read(config, debug=0, stop=None):
if stop.is_set():
return False
events.append("read started")
assert stop.wait(5)
events.append("disconnected")
return False
monkeypatch.setattr(server, "ecoflow_collect_data", bluetooth_read)
# The second device waits for the first one. It must not start a read.
devices = {
name: server.DeviceState(ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"))
for name in ("first", "second")
}
stop = threading.Event()
threads = server.start_polling(devices, stop)
while not events:
time.sleep(0.01)
server.stop_polling(threads, stop)
assert events == ["read started", "disconnected"]
assert not any(thread.is_alive() for thread in threads)
def test_stop_polling_reports_a_read_that_does_not_end(monkeypatch, capsys):
started, release = threading.Event(), threading.Event()
def stuck_read(device, debug=0, stop=None):
started.set()
release.wait(5)
monkeypatch.setattr(server, "collect_device_data", stuck_read)
monkeypatch.setattr(server, "POLL_STOP_TIMEOUT", 0.05)
stop = threading.Event()
threads = server.start_polling({"stuck": server.DeviceState(ser=Mock())}, stop)
try:
assert started.wait(5)
server.stop_polling(threads, stop)
assert "server: no clean stop in 0.05 seconds for poll-stuck" in capsys.readouterr().out
finally:
release.set()
for thread in threads:
thread.join(5)
assert not any(thread.is_alive() for thread in threads)