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/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 c21d679..2ee3d56 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -1,4 +1,5 @@ from enum import Enum +import inspect import logging from typing import Any, Dict, Optional, Callable, List, Union import asyncio @@ -148,6 +149,7 @@ class EventDispatcher: logger.debug( f"Dispatching event: {event.type}, {event.payload}, {event.attributes}" ) + for subscription in self.subscriptions.copy(): # Check if event type matches if ( @@ -165,15 +167,24 @@ class EventDispatcher: 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 the call back asychronously + 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: self.running = True diff --git a/src/meshcore/meshcore.py b/src/meshcore/meshcore.py index 5b21871..04db83e 100644 --- a/src/meshcore/meshcore.py +++ b/src/meshcore/meshcore.py @@ -132,6 +132,7 @@ class MeshCore: auto_reconnect: bool = False, max_reconnect_attempts: int = 3, ) -> "MeshCore": + """ Create and connect a MeshCore instance using BLE connection. @@ -141,7 +142,6 @@ class MeshCore: If provided, 'address' is ignored for connection but can be used for identification. """ - connection = BLEConnection(address=address, client=client) mc = cls( @@ -152,6 +152,7 @@ class MeshCore: auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, ) + await mc.connect() return mc diff --git a/src/meshcore/serial_cx.py b/src/meshcore/serial_cx.py index ab48d2b..e547fc9 100644 --- a/src/meshcore/serial_cx.py +++ b/src/meshcore/serial_cx.py @@ -22,6 +22,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): @@ -29,20 +30,18 @@ class SerialConnection: def connection_made(self, transport): self.cx.transport = transport - logger.debug("port opened") - if ( - isinstance(transport, serial_asyncio.SerialTransport) - and transport.serial - ): - transport.serial.rts = ( - False # You can manipulate Serial object via transport - ) + logger.debug('port opened') + if isinstance(transport, serial_asyncio.SerialTransport) and transport.serial: + transport.serial.rts = False # You can manipulate Serial object via transport + 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") + logger.debug('Serial port closed') + self.cx._connected_event.clear() + if self.cx._disconnect_callback: asyncio.create_task(self.cx._disconnect_callback("serial_disconnect")) @@ -56,6 +55,8 @@ class SerialConnection: """ Connects to the device """ + self._connected_event.clear() + loop = asyncio.get_running_loop() await serial_asyncio.create_serial_connection( loop, @@ -64,7 +65,7 @@ class SerialConnection: baudrate=self.baudrate, ) - await asyncio.sleep(self.cx_dly) # wait for cx to establish + await self._connected_event.wait() logger.info("Serial Connection started") return self.port @@ -109,6 +110,7 @@ class SerialConnection: if self.transport: self.transport.close() self.transport = None + self._connected_event.clear() logger.debug("Serial Connection closed") def set_disconnect_callback(self, callback): diff --git a/tests/test_ble_connection.py b/tests/test_ble_connection.py index 089b6f1..15512e0 100644 --- a/tests/test_ble_connection.py +++ b/tests/test_ble_connection.py @@ -8,9 +8,9 @@ from meshcore.ble_cx import ( 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. @@ -34,6 +34,7 @@ class TestBLEConnection(unittest.TestCase): 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. @@ -65,17 +66,15 @@ class TestBLEConnection(unittest.TestCase): 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_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__': -if __name__ == "__main__": unittest.main()