From 3c2f0b493b1664208330c532dc83fbf1e8d4ce6b Mon Sep 17 00:00:00 2001 From: Andrey Pokhilko Date: Sat, 27 Jun 2020 13:45:53 +0300 Subject: [PATCH] Experiment with Bleak changes (#55) * Introduce driver that works with Bleak, enables to use BLE devices in windows without a need of external BLE dongle. * Fix issues in auto review. * Add method description and UT. * Fix docstring to comply with pep257 * Experiment * Make test only work in 3.7+ * Fix versions * One more try * Kick it * Kick * cmon * Dummm * yeah yeah * Add * Fix version Co-authored-by: mgr --- .travis.yml | 23 ++--- pylgbst/__init__.py | 15 +++ pylgbst/comms/cbleak.py | 204 ++++++++++++++++++++++++++++++++++++++++ setup.py | 1 + tests/test_cbleak.py | 61 ++++++++++++ 5 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 pylgbst/comms/cbleak.py create mode 100644 tests/test_cbleak.py diff --git a/.travis.yml b/.travis.yml index c3b7a23..2f8b4d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,8 @@ sudo: false language: python -virtualenv: - system_site_packages: true - -matrix: - include: - - os: linux - python: 2.7 - - os: linux - python: 3.5 +python: + - 3.6 + - 3.8 addons: apt: @@ -21,16 +15,17 @@ addons: - libdbus-1-dev - libdbus-glib-1-dev - libgirepository-1.0-1 + - libgirepository1.0-dev - - python-dbus - - python-gi - - python3-dbus - - python3-gi + - bluez install: - wget https://github.com/labapart/gattlib/releases/download/dev/gattlib_dbus_0.2-dev_x86_64.deb - sudo dpkg -i gattlib_dbus_0.2-dev_x86_64.deb - - pip install codecov codacy-coverage nose-exclude pygatt gatt pexpect bluepy + - pip install codecov codacy-coverage nose-exclude pygatt gatt pexpect bluepy bleak packaging dbus-python pygobject + - pip install --upgrade attrs +env: + - READTHEDOCS=True script: - coverage run --omit="examples/*" --source=. -m nose tests -v --exclude-dir=examples diff --git a/pylgbst/__init__.py b/pylgbst/__init__.py index ae6594f..53ecce1 100644 --- a/pylgbst/__init__.py +++ b/pylgbst/__init__.py @@ -37,6 +37,20 @@ def get_connection_bluepy(controller='hci0', hub_mac=None): return BluepyConnection(controller).connect(hub_mac) +def get_connection_bleak(controller='hci0', hub_mac=None): + """ + Return connection based with Bleak API as an endpoint. + + :param controller: Not used, kept for compatibility with others. + :param hub_mac: Optional Lego HUB MAC to connect to. + :return: Driver object. + """ + del controller # to prevent code analysis warning + from pylgbst.comms.cbleak import BleakDriver + + return BleakDriver(hub_mac) + + def get_connection_auto(controller='hci0', hub_mac=None): fns = [ get_connection_bluepy, @@ -44,6 +58,7 @@ def get_connection_auto(controller='hci0', hub_mac=None): get_connection_gatt, get_connection_gattool, get_connection_gattlib, + get_connection_bleak, ] conn = None diff --git a/pylgbst/comms/cbleak.py b/pylgbst/comms/cbleak.py new file mode 100644 index 0000000..427019c --- /dev/null +++ b/pylgbst/comms/cbleak.py @@ -0,0 +1,204 @@ +import asyncio +import logging +import queue +import threading +import time + +from bleak import BleakClient, discover + +from pylgbst.comms import Connection, MOVE_HUB_HW_UUID_CHAR + +log = logging.getLogger('comms-bleak') + +# Queues to handle request / responses. Acts as a buffer between API and async BLE driver +resp_queue = queue.Queue() +req_queue = queue.Queue() + + +class BleakDriver(object): + """Driver that provides interface between API and Bleak.""" + + def __init__(self, hub_mac=None): + """ + Initialize new object of Bleak Driver class. + + :param hub_mac: Optional Lego HUB MAC to connect to. + """ + self.hub_mac = hub_mac + self._handler = None + self._abort = False + self._connection_thread = None + self._processing_thread = None + + def set_notify_handler(self, handler): + """ + Set handler function used to communicate with an API. + + :param handler: Handler function called by driver when received data + :return: None + """ + self._handler = handler + + def enable_notifications(self): + """ + Enable notifications, in our cases starts communication threads. + + We cannot do this earlier, because API need to fist set notification handler. + :return: None + """ + self._connection_thread = threading.Thread(target=lambda: asyncio.run(self._bleak_thread())) + self._connection_thread.daemon = True + self._connection_thread.start() + + self._processing_thread = threading.Thread(target=self._processing) + self._processing_thread.daemon = True + self._processing_thread.start() + + async def _bleak_thread(self): + bleak = BleakConnection() + await bleak.connect(self.hub_mac) + await bleak.set_notify_handler(self._safe_handler) + # After connecting, need to send any data or hub will drop the connection, + # below command is Advertising name request update + await bleak.write_char(MOVE_HUB_HW_UUID_CHAR, bytearray([0x05, 0x00, 0x01, 0x01, 0x05])) + while not self._abort: + await asyncio.sleep(0.1) + if req_queue.qsize() != 0: + data = req_queue.get() + await bleak.write(data[0], data[1]) + + @staticmethod + def _safe_handler(handler, data): + resp_queue.put((handler, data)) + + def _processing(self): + while not self._abort: + if resp_queue.qsize() != 0: + msg = resp_queue.get() + self._handler(msg[0], msg[1]) + + time.sleep(0.1) + + def write(self, handle, data): + """ + Send data to given handle number. + + :param handle: Handle number that will be translated into characteristic uuid + :param data: data to send + :raises ConnectionError" When internal threads are not working + :return: None + """ + if not self._connection_thread.is_alive() or not self._processing_thread.is_alive(): + raise ConnectionError('Something went wrong, communication threads not functioning.') + + req_queue.put((handle, data)) + + def disconnect(self): + """ + Disconnect and stops communication threads. + + :return: None + """ + self._abort = True + + def is_alive(self): + """ + Indicate whether driver is functioning or not. + + :return: True if driver is functioning; False otherwise. + """ + if self._connection_thread is not None and self._processing_thread is not None: + return self._connection_thread.is_alive() and self._processing_thread.is_alive() + else: + return False + + +class BleakConnection(Connection): + """Bleak driver for communicating with BLE device.""" + + def __init__(self): + """Initialize new instance of BleakConnection class.""" + Connection.__init__(self) + self.loop = asyncio.get_event_loop() + + self._device = None + self._client = None + logging.getLogger('bleak.backends.dotnet.client').setLevel(logging.getLogger().level) + + async def connect(self, hub_mac=None): + """ + Connect to device. + + :param hub_mac: Optional Lego HUB MAC to connect to. + :raises ConnectionError: When cannot connect to given MAC or name matching fails. + :return: None + """ + log.info("Discovering devices... Press Green button on lego MoveHub") + devices = await discover() + log.debug("Devices: %s", devices) + + for dev in devices: + log.debug(dev) + address = dev.address + name = dev.name + if self._is_device_matched(address, name, hub_mac): + log.info('Device matched') + self._device = dev + break + + if not self._device: + raise ConnectionError('Device not found.') + + self._client = BleakClient(self._device.address, self.loop) + status = await self._client.connect() + log.debug('Connection status: {status}'.format(status=status)) + + async def write(self, handle, data): + """ + Send data to given handle number. + + If handle cannot be found in service description, hardcoded LEGO uuid will be used. + :param handle: Handle number that will be translated into characteristic uuid + :param data: data to send + :return: None + """ + log.debug('Request: {handle} {payload}'.format(handle=handle, payload=[hex(x) for x in data])) + desc = self._client.services.get_descriptor(handle) + if desc is None: + # dedicated handle not found, try to send by using LEGO Move Hub default characteristic + await self._client.write_gatt_char(MOVE_HUB_HW_UUID_CHAR, data) + else: + await self._client.write_gatt_char(desc.characteristic_uuid, data) + + async def write_char(self, characteristic_uuid, data): + """ + Send data to given handle number. + + :param characteristic_uuid: Characteristic uuid used to send data + :param data: data to send + :return: None + """ + await self._client.write_gatt_char(characteristic_uuid, data) + + async def set_notify_handler(self, handler): + """ + Set notification handler. + + :param handler: Handle function to be called when receive any data. + :return: None + """ + + def c(handle, data): + log.debug('Response: {handle} {payload}'.format(handle=handle, payload=[hex(x) for x in data])) + handler(handle, data) + + await self._client.start_notify(MOVE_HUB_HW_UUID_CHAR, c) + + def is_alive(self): + """ + To keep compatibility with the driver interface. + + This method does nothing. + :return: None. + """ + pass diff --git a/setup.py b/setup.py index 7ab1baf..e7c92d2 100644 --- a/setup.py +++ b/setup.py @@ -19,5 +19,6 @@ setup( "gattlib": ["gattlib"], "pygatt": ["pygatt", "pexpect"], "bluepy": ["bluepy"], + "bleak": ["bleak"], }, ) diff --git a/tests/test_cbleak.py b/tests/test_cbleak.py new file mode 100644 index 0000000..ad35a10 --- /dev/null +++ b/tests/test_cbleak.py @@ -0,0 +1,61 @@ +import sys +import time +import unittest + +import bleak +from packaging import version + +import pylgbst +import pylgbst.comms.cbleak as cbleak + +bleak.BleakClient = object() +bleak.discover = object() + +last_response = None +lt37 = version.parse(sys.version.split(' ')[0]) < version.parse("3.7") + + +class BleakDriverTest(unittest.TestCase): + def test_driver_creation(self): + connection = pylgbst.get_connection_bleak() + self.assertIsInstance(connection, cbleak.BleakDriver) + self.assertFalse(connection.is_alive(), 'Checking that factory returns not started driver') + + @unittest.skipIf(lt37, "Python version is too low") + def test_communication(self): + driver = cbleak.BleakDriver() + + async def fake_thread(): + print('Fake thread initialized') + while not driver._abort: + time.sleep(0.1) + if cbleak.req_queue.qsize() != 0: + print('Received data, sending back') + data = cbleak.req_queue.get() + cbleak.resp_queue.put(data) + + driver._bleak_thread = fake_thread + driver.set_notify_handler(BleakDriverTest.validation_handler) + driver.enable_notifications() + + time.sleep(0.5) # time for driver initialization + self.assertTrue(driver.is_alive(), 'Checking that driver starts') + handle = 0x32 + data = [0xD, 0xE, 0xA, 0xD, 0xB, 0xE, 0xE, 0xF] + driver.write(handle, data) + time.sleep(0.5) # processing time + self.assertEqual(handle, last_response[0], 'Verifying response handle') + self.assertEqual(data, last_response[1], 'Verifying response data') + + driver.disconnect() + time.sleep(0.5) # processing time + self.assertFalse(driver.is_alive()) + + @staticmethod + def validation_handler(handle, data): + global last_response + last_response = (handle, data) + + +if __name__ == '__main__': + unittest.main()