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
+74
View File
@@ -9,6 +9,8 @@ import asyncio
import importlib.util
import struct
import sys
import threading
import time
from argparse import Namespace
from dataclasses import asdict
from itertools import permutations
@@ -538,6 +540,78 @@ def test_successful_collection_logs_no_failure(monkeypatch, capsys):
assert capsys.readouterr().out == ""
def _stoppable_device(monkeypatch, **steps):
"""Stand-in device that records whether its disconnect ran in full."""
events = []
async def disconnect():
events.append("disconnect started")
await asyncio.sleep(0.05)
events.append("disconnect done")
fake = Mock(
connect=AsyncMock(return_value=True), wait_authenticated=AsyncMock(return_value=True),
wait_for_measurements=AsyncMock(return_value=set()), disconnect=disconnect,
_pkt_count=1, last_error=None,
)
fake.configure_mock(**steps)
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
monkeypatch.setattr("bmspy.ecoflow_bt._STOP_POLL_SECONDS", 0.01)
monkeypatch.setattr("bmspy.ecoflow_bt._last_missing", {})
return fake, events
@pytest.mark.parametrize("step", ["connect", "wait_authenticated", "wait_for_measurements"])
def test_stop_during_a_collection_disconnects_before_it_returns(monkeypatch, capsys, step):
"""A server that stops in the middle of a read must not leave the link open."""
stop = threading.Event()
async def interrupted(*args, **kwargs):
stop.set()
await asyncio.sleep(30)
fake, events = _stoppable_device(monkeypatch, **{step: interrupted})
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
started = time.monotonic()
assert collect_data(config, stop=stop) is False
assert time.monotonic() - started < 5
assert events == ["disconnect started", "disconnect done"]
lines = capsys.readouterr().out.splitlines()
assert len(lines) == 1
assert lines[0].endswith(f"ecoflow_bt [{ADDRESS}]: read stopped, device disconnected")
def test_stop_during_the_disconnect_lets_the_disconnect_finish(monkeypatch):
stop = threading.Event()
fake, events = _stoppable_device(monkeypatch)
slow_disconnect = fake.disconnect
async def disconnect():
stop.set()
await slow_disconnect()
fake.disconnect = disconnect
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
assert collect_data(config, stop=stop) is fake.snapshot
assert events == ["disconnect started", "disconnect done"]
def test_collection_does_not_connect_after_stop(monkeypatch):
stop = threading.Event()
stop.set()
device = Mock()
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", device)
assert collect_data(initialise_device(ADDRESS, SERIAL, "test-user"), stop=stop) is False
device.assert_not_called()
def test_collection_with_an_unset_stop_event_returns_the_snapshot(monkeypatch):
fake, events = _stoppable_device(monkeypatch)
config = initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0)
assert collect_data(config, stop=threading.Event()) is fake.snapshot
assert events == ["disconnect started", "disconnect done"]
def test_connect_records_the_reason_for_a_failure(monkeypatch):
client = Mock(connect=AsyncMock(side_effect=TimeoutError("no answer")), disconnect=AsyncMock(), is_connected=False)
monkeypatch.setitem(sys.modules, "bleak", SimpleNamespace(BleakClient=Mock(return_value=client)))
+61 -2
View File
@@ -25,6 +25,7 @@ def _poll_once(devices, stop, debug=0):
for device in devices.values():
device.data = collect_device_data(device, debug)
device.polled.set()
return []
# ---------------------------------------------------------------------------
@@ -217,6 +218,39 @@ listener.accept()
assert result.stderr.strip() == "terminating"
assert not socket_path.exists()
def test_sigterm_during_a_bluetooth_read_waits_for_the_disconnect(self, tmp_path):
"""The server process itself, with a stand-in for the Bluetooth read."""
socket_path = tmp_path / "bms.sock"
script = """
import os
import signal
import sys
import threading
from bmspy import server
def bluetooth_read(config, debug=0, stop=None):
if stop.is_set():
return False
print("read started", flush=True)
threading.Timer(0.1, os.kill, args=(os.getpid(), signal.SIGTERM)).start()
stop.wait(10)
print("disconnected", flush=True)
return False
server.ecoflow_collect_data = bluetooth_read
sys.argv = ["bmspy-server", "--socket", sys.argv[1],
"-d", "river@02:00:00:00:00:01/R601TEST00000001/test-user"]
server.main()
"""
result = subprocess.run(
[sys.executable, "-c", script, str(socket_path)],
capture_output=True, text=True, timeout=30,
)
assert result.stdout.splitlines() == ["read started", "disconnected"]
assert result.returncode == 1
assert result.stderr.strip() == "terminating"
assert not socket_path.exists()
# ---------------------------------------------------------------------------
# socket_cleanup
@@ -765,7 +799,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \
patch("bmspy.server.atexit.register"), \
patch("bmspy.server.initialise_serial"), \
patch("bmspy.server.start_polling"), \
patch("bmspy.server.start_polling", return_value=[]), \
patch("bmspy.server.os.path.isdir", return_value=True), \
patch.object(socket_module.socket, "accept", side_effect=KeyboardInterrupt):
from bmspy.server import main as server_main
@@ -773,6 +807,31 @@ class TestServerMain:
server_main()
assert exit_info.value.code == 1
def test_exit_closes_the_socket_and_stops_polling(self, tmp_path):
sock_path = str(tmp_path / "server_stop.sock")
threads = [object()]
listeners = []
original_listen = socket_module.socket.listen
def listen(self, backlog=1):
listeners.append(self)
return original_listen(self, backlog)
with patch("sys.argv", ["bmspy-server", "--socket", sock_path, "--device", "/dev/ttyUSB0"]), \
patch("bmspy.server.signal.signal"), \
patch("bmspy.server.atexit.register"), \
patch("bmspy.server.initialise_serial"), \
patch("bmspy.server.start_polling", return_value=threads) as start, \
patch("bmspy.server.stop_polling") as stop_polling, \
patch("bmspy.server.os.path.isdir", return_value=True), \
patch.object(socket_module.socket, "listen", listen), \
patch.object(socket_module.socket, "accept", side_effect=KeyboardInterrupt):
from bmspy.server import main as server_main
with pytest.raises(SystemExit):
server_main()
stop_polling.assert_called_once_with(threads, start.call_args.args[1])
assert listeners[0].fileno() == -1
def test_socket_read_error_logs_and_continues(self, tmp_path, capsys):
"""Test that read_request errors are caught and logged."""
sock_path = str(tmp_path / "server_err.sock")
@@ -1098,7 +1157,7 @@ class TestServerMain:
patch("bmspy.server.signal.signal"), \
patch("bmspy.server.atexit.register"), \
patch("bmspy.server.initialise_serial"), \
patch("bmspy.server.start_polling"), \
patch("bmspy.server.start_polling", return_value=[]), \
patch("bmspy.server.os.path.isdir", return_value=True), \
patch.object(socket_module.socket, "accept", side_effect=OSError("accept failed")) as accept:
from bmspy.server import main as server_main
+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)