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