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