Search code examples
pythonbluetooth-lowenergypython-asyncioqthread

Python: Why does BleakClient disconnect from Bluetooth device when function exits?


I have created a "Bluetooth" class that uses BleakScanner and BleakClient. When I call the class's function that instantiates BleakClient, the connection is successfully established. However, the connection is lost after the function exits.

Here is the Bluetooth class:

import asyncio
from bleak import BleakScanner, BleakClient

class Bluetooth:
    def __init__(self, name, timeout):
        self._scanner = BleakScanner(self.detection_callback)
        self.scanning = asyncio.Event()
        self.name = name
        self.timeout = timeout
        self.device = None
        self.connected = False
        self.client = None

    def detection_callback(self, device, advertisement_data):
        print(f'Device: {device}')
        if device.name == self.name:
            print(f'Found BLE device "{device.name}" with address {device.address}')
            self.device = device
            self.scanning.clear()

    async def scan(self):
        print('Scanning...')
        loop = asyncio.get_event_loop()
        await self._scanner.start()
        self.scanning.set()
        end_time = loop.time() + self.timeout
        while self.scanning.is_set():
            if loop.time() > end_time:
                print('Scanning timed out.')
                self.scanning.clear()
            await asyncio.sleep(0.1)
        await self._scanner.stop()
        print('Done scanning...')

    async def connect(self):
        print('Connecting...')
        async with BleakClient(self.device) as self.client:
            if not self.client.is_connected:
                print('Failed to connect')
            else:
                self.connected = True
                print(f'Connected to: {self.device.address}')

And this is the function that uses the class:

    # Connect to bluetooth device by name            
    def ble_connect(self, name, timeout):
        # The "bleak" Bluetooth module uses asyncio, which uses an event loop
        # Because this is not run in the main thread, we need to create an event loop
        # This is done at the top of the thread
        self.ble = Bluetooth(name, timeout)
        self.loop.run_until_complete(self.ble.scan())
        if self.ble.device is not None:
            self.loop.run_until_complete(self.ble.connect())
            if self.ble.client.is_connected:
                print('ble_connect: connected')
                return True
            else:
                print('ble_connect: not connected')
                return False
        else:
            print(f'ble_connect: {name} not found')
            return False

My app is a GUI using PySide6, and the function ble_connect() is defined in and called from a QThread. This is the output when ble_connect() is called:

Scanning...
Device: 4D:04:32:3D:8E:03: None
Device: 4D:04:32:3D:8E:03: None
Device: F4:F9:51:CC:18:54: None
Device: F4:F9:51:CC:18:54: None
Device: E0:E2:E6:33:A5:BA: None
Device: E0:E2:E6:33:A5:BA: SliceCPE633A5B8
Device: F1:1C:5B:4C:EF:11: Ion Pro RT
Device: F1:1C:5B:4C:EF:11: Ion Pro RT
Device: 34:86:5D:18:A1:02: None
Device: 34:86:5D:18:A1:02: WCBProj5D18A100
Found BLE device "WCBProj5D18A100" with address 34:86:5D:18:A1:02
Done scanning...
Connecting...
Connected to: 34:86:5D:18:A1:02
ble_connect: not connected

This is my first time working with asyncio, and I suspect I am breaking the rules in some way.


Solution

  • The whole idea of context manager in Python - the objects that are used in a with statement block, is to ensure the resource is correctly finalized when its use is over.

    So, whenever you see a with block, and its expressions starts something, that something is terminated, unconditionally when that block is over - i.e. whenever there is an un-indent, or a return statement is executed within the with block.

    In your code, the connections is stablished here:

        async def connect(self):
            print('Connecting...')
            async with BleakClient(self.device) as self.client:
                if not self.client.is_connected:
                    print('Failed to connect')
                else:
                    self.connected = True
                    print(f'Connected to: {self.device.address}')
    
    

    And that is it: when the with block finishes, along with the end of the function, the connection is undone.

    The workaround for that is simple - the correct way of doing it might require a bit higher level thinking, like transform your own class in a contet manager.

    But just to get start, without caring about other things: you can always call the __enter__ (or in this case, await the __aenter__) method of the object you would be using in a with statement. That statement ensures the counterpart __exit__ or __aexit__ is called at the end of the block: you just have to make your code so that this counterpart is called when the connection is to be closed. It might be a disconnect method, for example:

    ...
    class Bluetooth:
        ...
        async def connect(self):
            self._client_context = BleakClient(self.device)
            print('Connecting...')
            self.client = await self._client_context(self.device)
            if not self.client.is_connected:
                print('Failed to connect')
            else:
                self.connected = True
                print(f'Connected to: {self.device.address}')
            # no "with" block = no disconnection at the end of the function
            
        async def disconnect(self):
            await self._client_context.__aexit__(None, None, None)
            
    

    Note that the big advantage of the with command is to ensure the resource is finalized - in this case, the connection undone - even if an exception takes place anywhere in the with block. I suspect that the underlying library (bleak) will terminate the connection if your program does not handle an exception and exits - but that might not be the case, and you'd have a dangling connection after the program exits if you fail to call the __aexit__ method. So, take the necessary steps in order for it to always run.

    Another way of doing this, and which might be termed "more correct" is to keep the use of the with command, and pass your connect method a callback, which is itself a function that will start and run everything that you want to do with this connection active.

    This way, only when that function returns (or raises an exception) the with block is exited and the connection finalized. This is somewhat "flipping the program inside out" - it might be more elegant, and preserve the guarrantees the with statement gives you.

    Otherwise, as you mentioned, you are running the connection alone in a thread just for that: use a queue or other synchronization primitive so that you call the connect method, and it will block inside the with block, until the code in the thread that is using the connection is done. Actually, since it is cross-thread and cross-asyncio, you will need the threaded-versin of these primitives -check threading.Event - I think it would be useful: https://docs.python.org/3/library/threading.html#event-objects