From f4d3be1360a6263e9e86c38884e2c4a68dce6ea9 Mon Sep 17 00:00:00 2001 From: Ventz Petkov Date: Tue, 5 Aug 2025 15:52:44 -0400 Subject: [PATCH 1/5] Fix: Improved BLE Connection Logic on macOS --- pytest.ini | 1 - src/meshcore/ble_cx.py | 95 +++++++++++++++++++++++------------- src/meshcore/meshcore.py | 15 ++++-- tests/test_ble_connection.py | 71 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 tests/test_ble_connection.py diff --git a/pytest.ini b/pytest.ini index d280de0..eea2c18 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1 @@ [pytest] -asyncio_mode = auto \ No newline at end of file diff --git a/src/meshcore/ble_cx.py b/src/meshcore/ble_cx.py index e68d7b4..517ef1b 100644 --- a/src/meshcore/ble_cx.py +++ b/src/meshcore/ble_cx.py @@ -1,57 +1,80 @@ -""" - mccli.py : CLI interface to MeschCore BLE companion app """ +mccli.py : CLI interface to MeschCore BLE companion app +""" + import asyncio import logging -# Get logger -logger = logging.getLogger("meshcore") - from bleak import BleakClient, BleakScanner from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakDeviceNotFoundError +# Get logger +logger = logging.getLogger("meshcore") + UART_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" UART_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" UART_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" + class BLEConnection: - def __init__(self, address): - """ Constructor : specify address """ + def __init__(self, address=None, client=None): + """ + Constructor: specify address or an existing BleakClient. + + Args: + address (str, optional): The Bluetooth address of the device. + client (BleakClient, optional): An existing BleakClient instance. + """ self.address = address self._user_provided_address = address - self.client = None + self.client = client self.rx_char = None self._disconnect_callback = None async def connect(self): """ - Connects to the device + Connects to the device. - Returns : the address used for connection + If a BleakClient was provided to the constructor, it uses that. + Otherwise, it will scan or connect based on the provided address. + + Returns: + The address used for connection, or None on failure. """ - logger.debug(f"Connecting existing connection: {self.client} with address {self.address}") - def match_meshcore_device(_: BLEDevice, adv: AdvertisementData): - """ Filter to mach MeshCore devices """ - if not adv.local_name is None\ - and adv.local_name.startswith("MeshCore")\ - and (self.address is None or self.address in adv.local_name) : - return True - return False + logger.debug(f"Connecting with client: {self.client}, address: {self.address}") - if self.address is None or self.address == "" or len(self.address.split(":")) != 6: - scanner = BleakScanner() - logger.info("Scanning for devices") - device = await scanner.find_device_by_filter(match_meshcore_device) - if device is None: - return None - logger.info(f"Found device : {device}") - self.client = BleakClient(device, disconnected_callback=self.handle_disconnect) + if self.client: + logger.debug("Using pre-configured BleakClient.") + # If a client is already provided, ensure its disconnect callback is set + self.client._disconnected_callback = self.handle_disconnect self.address = self.client.address else: - self.client = BleakClient(self.address, disconnected_callback=self.handle_disconnect) + + def match_meshcore_device(_: BLEDevice, adv: AdvertisementData): + """Filter to match MeshCore devices.""" + if adv.local_name and adv.local_name.startswith("MeshCore"): + if self.address is None or self.address in adv.local_name: + return True + return False + + if self.address is None or ":" not in self.address: + logger.info("Scanning for devices...") + device = await BleakScanner.find_device_by_filter(match_meshcore_device) + if device is None: + logger.warning("No MeshCore device found during scan.") + return None + logger.info(f"Found device: {device}") + self.client = BleakClient( + device, disconnected_callback=self.handle_disconnect + ) + self.address = self.client.address + else: + self.client = BleakClient( + self.address, disconnected_callback=self.handle_disconnect + ) try: await self.client.connect() @@ -72,24 +95,26 @@ class BLEConnection: return self.address def handle_disconnect(self, client: BleakClient): - """ Callback to handle disconnection """ - logger.debug(f"BLE device disconnected: {client.address} (is_connected: {client.is_connected})") + """Callback to handle disconnection""" + logger.debug( + f"BLE device disconnected: {client.address} (is_connected: {client.is_connected})" + ) # Reset the address we found to what user specified # this allows to reconnect to the same device self.address = self._user_provided_address - + if self._disconnect_callback: asyncio.create_task(self._disconnect_callback("ble_disconnect")) - + def set_disconnect_callback(self, callback): """Set callback to handle disconnections.""" self._disconnect_callback = callback - def set_reader(self, reader) : + def set_reader(self, reader): self.reader = reader def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray): - if not self.reader is None: + if self.reader is not None: asyncio.create_task(self.reader.handle_rx(data)) async def send(self, data): @@ -99,8 +124,8 @@ class BLEConnection: if not self.rx_char: logger.error("RX characteristic not found") return False - await self.client.write_gatt_char(self.rx_char, bytes(data), response=False) - + await self.client.write_gatt_char(self.rx_char, bytes(data), response=True) + async def disconnect(self): """Disconnect from the BLE device.""" if self.client and self.client.is_connected: diff --git a/src/meshcore/meshcore.py b/src/meshcore/meshcore.py index 0ce7a6c..699cc92 100644 --- a/src/meshcore/meshcore.py +++ b/src/meshcore/meshcore.py @@ -83,14 +83,19 @@ class MeshCore: return mc @classmethod - async def create_ble(cls, address: Optional[str] = None, debug: bool = False, only_error:bool=False, default_timeout=None, + async def create_ble(cls, address: Optional[str] = None, client=None, debug: bool = False, only_error:bool=False, default_timeout=None, auto_reconnect: bool = False, max_reconnect_attempts: int = 3) -> 'MeshCore': - """Create and connect a MeshCore instance using BLE connection - - If address is None, it will scan for and connect to the first available MeshCore device. + """ + Create and connect a MeshCore instance using BLE connection. + + Args: + address (str, optional): The Bluetooth address of the device. + client (BleakClient, optional): An existing BleakClient instance to use. + If provided, 'address' is ignored for connection + but can be used for identification. """ - connection = BLEConnection(address) + connection = BLEConnection(address=address, client=client) mc = cls(connection, debug=debug, only_error=only_error, default_timeout=default_timeout, auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts) diff --git a/tests/test_ble_connection.py b/tests/test_ble_connection.py new file mode 100644 index 0000000..dcd7c56 --- /dev/null +++ b/tests/test_ble_connection.py @@ -0,0 +1,71 @@ +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from meshcore.ble_cx import BLEConnection, UART_SERVICE_UUID, UART_TX_CHAR_UUID, UART_RX_CHAR_UUID + +class TestBLEConnection(unittest.TestCase): + @patch('meshcore.ble_cx.BleakClient') + def test_ble_connection_and_disconnection(self, mock_bleak_client): + """ + Tests the BLEConnection class for connecting and disconnecting from a BLE device. + """ + # Arrange + mock_client_instance = self._get_mock_bleak_client() + mock_bleak_client.return_value = mock_client_instance + + address = "00:11:22:33:44:55" + ble_conn = BLEConnection(address=address) + + # Act + asyncio.run(ble_conn.connect()) + asyncio.run(ble_conn.disconnect()) + + # Assert + mock_client_instance.connect.assert_called_once() + mock_client_instance.start_notify.assert_called_once_with(UART_TX_CHAR_UUID, ble_conn.handle_rx) + mock_client_instance.disconnect.assert_called_once() + + @patch('meshcore.ble_cx.BleakClient') + def test_send_data(self, mock_bleak_client): + """ + Tests the send method of the BLEConnection class. + """ + # Arrange + mock_client_instance = self._get_mock_bleak_client() + mock_bleak_client.return_value = mock_client_instance + + address = "00:11:22:33:44:55" + ble_conn = BLEConnection(address=address) + asyncio.run(ble_conn.connect()) + + # Act + data_to_send = b"Hello, BLE" + asyncio.run(ble_conn.send(data_to_send)) + + # Assert + ble_conn.rx_char.write_gatt_char.assert_called_once_with(ble_conn.rx_char, data_to_send, response=False) + + def _get_mock_bleak_client(self): + """ + Creates a mock BleakClient instance with all the necessary async methods and attributes. + """ + mock_client = MagicMock() + mock_client.connect = AsyncMock() + mock_client.disconnect = AsyncMock() + mock_client.start_notify = AsyncMock() + mock_client.write_gatt_char = AsyncMock() + mock_client.is_connected = True + + mock_service = MagicMock() + mock_char = MagicMock() + mock_char.uuid = UART_RX_CHAR_UUID + mock_char.write_gatt_char = mock_client.write_gatt_char + + mock_service.get_characteristic.return_value = mock_char + mock_client.services.get_service.return_value = mock_service + + return mock_client + +if __name__ == '__main__': + unittest.main() From 968e42c6c89c8d05d7a9ac8e4efb8a15afc8116d Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Tue, 5 Aug 2025 13:21:20 -0700 Subject: [PATCH 2/5] Add testing workflow --- .github/python-test.yml | 41 ++++++++++++++++++++++++++++++++++++ tests/test_ble_connection.py | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .github/python-test.yml diff --git a/.github/python-test.yml b/.github/python-test.yml new file mode 100644 index 0000000..d526bd6 --- /dev/null +++ b/.github/python-test.yml @@ -0,0 +1,41 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # TODO: Enable this later + # stop the build if there are Python syntax errors or undefined names + # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/tests/test_ble_connection.py b/tests/test_ble_connection.py index dcd7c56..dc2a649 100644 --- a/tests/test_ble_connection.py +++ b/tests/test_ble_connection.py @@ -44,7 +44,7 @@ class TestBLEConnection(unittest.TestCase): asyncio.run(ble_conn.send(data_to_send)) # Assert - ble_conn.rx_char.write_gatt_char.assert_called_once_with(ble_conn.rx_char, data_to_send, response=False) + ble_conn.rx_char.write_gatt_char.assert_called_once_with(ble_conn.rx_char, data_to_send, response=True) def _get_mock_bleak_client(self): """ From f9f7b11f4635d4733d3633486acbeed897a79e79 Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Tue, 5 Aug 2025 20:39:22 -0700 Subject: [PATCH 3/5] Fix event loop deadlock issue --- examples/ble_t1000_custom_vars.py | 2 +- src/meshcore/events.py | 23 +++++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/examples/ble_t1000_custom_vars.py b/examples/ble_t1000_custom_vars.py index db0a153..e1aa45d 100755 --- a/examples/ble_t1000_custom_vars.py +++ b/examples/ble_t1000_custom_vars.py @@ -4,7 +4,7 @@ import asyncio from meshcore import MeshCore from meshcore import BLEConnection -ADDRESS = "T1000_S" # node ble adress or name +ADDRESS = "Meshcore-lora-py-tester" # node ble adress or name async def main () : con = BLEConnection(ADDRESS) diff --git a/src/meshcore/events.py b/src/meshcore/events.py index fc81f80..94a6643 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -1,4 +1,5 @@ from enum import Enum +import inspect import logging from math import log from typing import Any, Dict, Optional, Callable, List, Union @@ -133,6 +134,7 @@ class EventDispatcher: while self.running: event = await self.queue.get() logger.debug(f"Dispatching event: {event.type}, {event.payload}, {event.attributes}") + for subscription in self.subscriptions.copy(): # Check if event type matches if subscription.event_type is None or subscription.event_type == event.type: @@ -142,14 +144,23 @@ class EventDispatcher: if not all(event.attributes.get(key) == value for key, value in subscription.attribute_filters.items()): continue - try: - result = subscription.callback(event.clone()) - if asyncio.iscoroutine(result): - await result - except Exception as e: - print(f"Error in event handler: {e}") + + # Fire and forget - don't await! + asyncio.create_task(self._execute_callback(subscription.callback, event.clone())) self.queue.task_done() + + async def _execute_callback(self, callback, event): + """Execute a callback with proper error handling""" + try: + if asyncio.iscoroutinefunction(callback): + await callback(event) + else: + result = callback(event) + if inspect.iscoroutine(result): + await result + except Exception as e: + logger.error(f"Error in event handler for {event.type}: {e}", exc_info=True) async def start(self): if not self.running: From 1ead55b5b53cffb5dc2c5c53b24d52e4b2a3b3be Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Tue, 5 Aug 2025 20:45:32 -0700 Subject: [PATCH 4/5] Update serial cx to more smartly await for connection --- src/meshcore/serial_cx.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/meshcore/serial_cx.py b/src/meshcore/serial_cx.py index 172baf4..002f703 100644 --- a/src/meshcore/serial_cx.py +++ b/src/meshcore/serial_cx.py @@ -20,6 +20,7 @@ class SerialConnection: self.inframe = b"" self._disconnect_callback = None self.cx_dly = cx_dly + self._connected_event = asyncio.Event() class MCSerialClientProtocol(asyncio.Protocol): def __init__(self, cx): @@ -30,12 +31,16 @@ class SerialConnection: logger.debug('port opened') if isinstance(transport, serial_asyncio.SerialTransport) and transport.serial: transport.serial.rts = False # You can manipulate Serial object via transport + # Signal that connection is established + self.cx._connected_event.set() def data_received(self, data): self.cx.handle_rx(data) def connection_lost(self, exc): logger.debug('Serial port closed') + # Clear the connected event + self.cx._connected_event.clear() if self.cx._disconnect_callback: asyncio.create_task(self.cx._disconnect_callback("serial_disconnect")) @@ -49,12 +54,16 @@ class SerialConnection: """ Connects to the device """ + # Clear any previous connection state + self._connected_event.clear() + loop = asyncio.get_running_loop() await serial_asyncio.create_serial_connection( loop, lambda: self.MCSerialClientProtocol(self), self.port, baudrate=self.baudrate) - await asyncio.sleep(self.cx_dly) # wait for cx to establish + # Wait for the actual connection to be established + await self._connected_event.wait() logger.info("Serial Connection started") return self.port @@ -99,6 +108,8 @@ class SerialConnection: if self.transport: self.transport.close() self.transport = None + # Clear the connected event + self._connected_event.clear() logger.debug("Serial Connection closed") def set_disconnect_callback(self, callback): From 43e2cfc724577732b954ea1825355124c86ba484 Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Tue, 5 Aug 2025 23:05:19 -0700 Subject: [PATCH 5/5] timing and test fixes --- examples/ble_t1000_custom_vars.py | 2 +- src/meshcore/ble_cx.py | 3 ++- src/meshcore/events.py | 2 +- src/meshcore/serial_cx.py | 5 ----- tests/test_ble_connection.py | 1 + 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/examples/ble_t1000_custom_vars.py b/examples/ble_t1000_custom_vars.py index e1aa45d..db0a153 100755 --- a/examples/ble_t1000_custom_vars.py +++ b/examples/ble_t1000_custom_vars.py @@ -4,7 +4,7 @@ import asyncio from meshcore import MeshCore from meshcore import BLEConnection -ADDRESS = "Meshcore-lora-py-tester" # node ble adress or name +ADDRESS = "T1000_S" # node ble adress or name async def main () : con = BLEConnection(ADDRESS) diff --git a/src/meshcore/ble_cx.py b/src/meshcore/ble_cx.py index 517ef1b..d0aa14d 100644 --- a/src/meshcore/ble_cx.py +++ b/src/meshcore/ble_cx.py @@ -49,7 +49,8 @@ class BLEConnection: if self.client: logger.debug("Using pre-configured BleakClient.") # If a client is already provided, ensure its disconnect callback is set - self.client._disconnected_callback = self.handle_disconnect + assert isinstance(self.client, BleakClient) + self.client.set_disconnected_callback(self.handle_disconnect) self.address = self.client.address else: diff --git a/src/meshcore/events.py b/src/meshcore/events.py index 94a6643..001992d 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -145,7 +145,7 @@ class EventDispatcher: for key, value in subscription.attribute_filters.items()): continue - # Fire and forget - don't await! + # Fire the call back asychronously asyncio.create_task(self._execute_callback(subscription.callback, event.clone())) self.queue.task_done() diff --git a/src/meshcore/serial_cx.py b/src/meshcore/serial_cx.py index 002f703..45c0034 100644 --- a/src/meshcore/serial_cx.py +++ b/src/meshcore/serial_cx.py @@ -31,7 +31,6 @@ class SerialConnection: logger.debug('port opened') if isinstance(transport, serial_asyncio.SerialTransport) and transport.serial: transport.serial.rts = False # You can manipulate Serial object via transport - # Signal that connection is established self.cx._connected_event.set() def data_received(self, data): @@ -39,7 +38,6 @@ class SerialConnection: def connection_lost(self, exc): logger.debug('Serial port closed') - # Clear the connected event self.cx._connected_event.clear() if self.cx._disconnect_callback: asyncio.create_task(self.cx._disconnect_callback("serial_disconnect")) @@ -54,7 +52,6 @@ class SerialConnection: """ Connects to the device """ - # Clear any previous connection state self._connected_event.clear() loop = asyncio.get_running_loop() @@ -62,7 +59,6 @@ class SerialConnection: loop, lambda: self.MCSerialClientProtocol(self), self.port, baudrate=self.baudrate) - # Wait for the actual connection to be established await self._connected_event.wait() logger.info("Serial Connection started") return self.port @@ -108,7 +104,6 @@ class SerialConnection: if self.transport: self.transport.close() self.transport = None - # Clear the connected event self._connected_event.clear() logger.debug("Serial Connection closed") diff --git a/tests/test_ble_connection.py b/tests/test_ble_connection.py index dc2a649..d15e9f7 100644 --- a/tests/test_ble_connection.py +++ b/tests/test_ble_connection.py @@ -44,6 +44,7 @@ class TestBLEConnection(unittest.TestCase): asyncio.run(ble_conn.send(data_to_send)) # Assert + assert(isinstance(ble_conn.rx_char, MagicMock)) ble_conn.rx_char.write_gatt_char.assert_called_once_with(ble_conn.rx_char, data_to_send, response=True) def _get_mock_bleak_client(self):