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
+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