From dc1c388fe8a955d6205650d9cb2ba8a3822d245c Mon Sep 17 00:00:00 2001 From: Vasily Loginov Date: Mon, 28 Jan 2019 20:13:07 +0100 Subject: [PATCH] Bluepy comm (#18) * Added support for bluepy communication backend. * Added bluepy information into the readme. * Added tests, fixed dependency specs in setup.py. * Fixed dep in travis. * Removed unused import. Added ability to fail the application on dispatcher thread error. * Fixed bluepy test to be more appropriate. * Properly handle hub mac if set. --- .travis.yml | 2 +- README.md | 2 + pylgbst/__init__.py | 6 ++ pylgbst/comms/cbluepy.py | 129 +++++++++++++++++++++++++++++++++++++++ setup.py | 1 + tests/test_cbluepy.py | 61 ++++++++++++++++++ 6 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 pylgbst/comms/cbluepy.py create mode 100644 tests/test_cbluepy.py diff --git a/.travis.yml b/.travis.yml index e60ddcb..eeae57c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,7 @@ addons: - python3-dbus - python3-gi install: -- pip install codecov nose-exclude gattlib pygatt gatt pexpect +- pip install codecov nose-exclude gattlib pygatt gatt pexpect bluepy script: coverage run --source=. `which nosetests` tests --nocapture --exclude-dir=examples diff --git a/README.md b/README.md index bfc4e93..1d3f15e 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ You have following options to install as Bluetooth backend: - `pip install pygatt` - [pygatt](https://github.com/peplin/pygatt) lib, works on both Windows and Linux - `pip install gatt` - [gatt](https://github.com/getsenic/gatt-python) lib, supports Linux, does not work on Windows - `pip install gattlib` - [gattlib](https://bitbucket.org/OscarAcena/pygattlib) - supports Linux, does not work on Windows, requires `sudo` +- `pip install bluepy` - [bluepy](https://github.com/IanHarvey/bluepy) lib, supports Linux, including Raspbian, which allows connection to the hub from the Raspberry PI Running on Windows requires [Bluegiga BLED112 Bluetooth Smart Dongle](https://www.silabs.com/products/wireless/bluetooth/bluetooth-low-energy-modules/bled112-bluetooth-smart-dongle) hardware piece, because no other hardware currently works on Windows with Python+BLE. @@ -272,6 +273,7 @@ There is optional parameter for `MoveHub` class constructor, accepting instance - use `GattConnection()` - if you use Gatt Backend on Linux (`gatt` library prerequisite) - use `GattoolConnection()` - if you use GattTool Backend on Linux (`pygatt` library prerequisite) - use `GattLibConnection()` - if you use GattLib Backend on Linux (`gattlib` library prerequisite) +- use `BluepyConnection()` - if you use Bluepy backend on Linux/Raspbian (`bluepy` library prerequisite) - pass instance of `DebugServerConnection` if you are using [Debug Server](#debug-server) (more details below). All the functions above have optional arguments to specify adapter name and MoveHub mac address. Please look function source code for details. diff --git a/pylgbst/__init__.py b/pylgbst/__init__.py index fa0138c..d3cff1b 100644 --- a/pylgbst/__init__.py +++ b/pylgbst/__init__.py @@ -29,9 +29,15 @@ def get_connection_gattlib(controller='hci0', hub_mac=None): return GattLibConnection(controller).connect(hub_mac) +def get_connection_bluepy(controller='hci0', hub_mac=None): + from pylgbst.comms.cbluepy import BluepyConnection + + return BluepyConnection(controller).connect(hub_mac) + def get_connection_auto(controller='hci0', hub_mac=None): fns = [ + get_connection_bluepy, get_connection_bluegiga, get_connection_gatt, get_connection_gattool, diff --git a/pylgbst/comms/cbluepy.py b/pylgbst/comms/cbluepy.py new file mode 100644 index 0000000..c0d5ae4 --- /dev/null +++ b/pylgbst/comms/cbluepy.py @@ -0,0 +1,129 @@ +import re +import logging +from threading import Thread, Event +import time +from contextlib import contextmanager +from enum import Enum + +from bluepy import btle + +from pylgbst.comms import Connection, LEGO_MOVE_HUB +from pylgbst.constants import MOVE_HUB_HW_UUID_CHAR +from pylgbst.utilities import str2hex, queue + +log = logging.getLogger('comms-bluepy') + +COMPLETE_LOCAL_NAME_ADTYPE = 9 +PROPAGATE_DISPATCHER_EXCEPTION = False + + +def _get_iface_number(controller): + """bluepy uses iface numbers instead of full names.""" + if not controller: + return None + m = re.search(r'hci(\d+)$', controller) + if not m: + raise ValueError('Cannot find iface number in {}.'.format(controller)) + return int(m.group(1)) + + +class BluepyDelegate(btle.DefaultDelegate): + def __init__(self, handler): + btle.DefaultDelegate.__init__(self) + + self._handler = handler + + def handleNotification(self, cHandle, data): + log.debug('Incoming notification') + self._handler(cHandle, data) + + +# We need a separate thread to wait for notifications, +# but calling peripheral's methods from different threads creates issues, +# so we will wrap all the calls into a thread +class BluepyThreadedPeripheral(object): + def __init__(self, addr, addrType, controller): + self._call_queue = queue.Queue() + self._addr = addr + self._addrType = addrType + self._iface_number = _get_iface_number(controller) + + self._disconnect_event = Event() + + self._dispatcher_thread = Thread(target=self._dispatch_calls) + self._dispatcher_thread.setDaemon(True) + self._dispatcher_thread.setName("Bluepy call dispatcher") + self._dispatcher_thread.start() + + def _dispatch_calls(self): + self._peripheral = btle.Peripheral(self._addr, self._addrType, self._iface_number) + try: + while not self._disconnect_event.is_set(): + try: + try: + method = self._call_queue.get(False) + method() + except queue.Empty: + pass + self._peripheral.waitForNotifications(1.) + except Exception as ex: + log.exception('Exception in call dispatcher thread', exc_info=ex) + if PROPAGATE_DISPATCHER_EXCEPTION: + log.error("Terminating dispatcher thread.") + raise + finally: + self._peripheral.disconnect() + + + def write(self, handle, data): + self._call_queue.put(lambda: self._peripheral.writeCharacteristic(handle, data)) + + def set_notify_handler(self, handler): + delegate = BluepyDelegate(handler) + self._call_queue.put(lambda: self._peripheral.withDelegate(delegate)) + + def disconnect(self): + self._disconnect_event.set() + + +class BluepyConnection(Connection): + def __init__(self, controller='hci0'): + Connection.__init__(self) + self._peripheral = None # :type BluepyThreadedPeripheral + self._controller = controller + + def connect(self, hub_mac=None): + log.debug("Trying to connect client to MoveHub with MAC: %s", hub_mac) + scanner = btle.Scanner() + + while not self._peripheral: + log.info("Discovering devices...") + scanner.scan(1) + devices = scanner.getDevices() + + for dev in devices: + address = dev.addr + addressType = dev.addrType + name = dev.getValueText(COMPLETE_LOCAL_NAME_ADTYPE) + log.debug("Found dev, name: {}, address: {}".format(name, address)) + + if (not hub_mac and name == LEGO_MOVE_HUB) or hub_mac == address: + logging.info("Found %s at %s", name, address) + self._peripheral = BluepyThreadedPeripheral(address, addressType, self._controller) + break + + return self + + def disconnect(self): + self._peripheral.disconnect() + + def write(self, handle, data): + log.debug("Writing to handle %s: %s", handle, str2hex(data)) + self._peripheral.write(handle, data) + + def set_notify_handler(self, handler): + self._peripheral.set_notify_handler(handler) + + def is_alive(self): + return True + diff --git a/setup.py b/setup.py index b0594cd..6914928 100644 --- a/setup.py +++ b/setup.py @@ -11,5 +11,6 @@ setup(name='pylgbst', 'gatt': ["gatt"], 'gattlib': ["gattlib"], 'pygatt': ["pygatt"], + 'bluepy': ["bluepy"], } ) diff --git a/tests/test_cbluepy.py b/tests/test_cbluepy.py new file mode 100644 index 0000000..d27e35d --- /dev/null +++ b/tests/test_cbluepy.py @@ -0,0 +1,61 @@ +import unittest +import time + +import pylgbst.comms.cbluepy as bp_backend + + +class PeripheralMock(object): + def __init__(self, addr, addrType, ifaceNumber): + pass + + def waitForNotifications(self, timeout): + pass + + def writeCharacteristic(self, handle, data): + pass + + def withDelegate(self, delegate): + pass + + def disconnect(self): + pass + +bp_backend.PROPAGATE_DISPATCHER_EXCEPTION = True +bp_backend.btle.Peripheral = lambda *args, **kwargs: PeripheralMock(*args, **kwargs) + + +class BluepyTestCase(unittest.TestCase): + def test_get_iface_number(self): + self.assertEqual(bp_backend._get_iface_number('hci0'), 0) + self.assertEqual(bp_backend._get_iface_number('hci10'), 10) + try: + bp_backend._get_iface_number('ads12') + self.fail('Missing exception for incorrect value') + except ValueError: + pass + + def test_delegate(self): + def _handler(handle, data): + _handler.called = True + delegate = bp_backend.BluepyDelegate(_handler) + delegate.handleNotification(123, 'qwe') + self.assertEqual(_handler.called, True) + + def test_threaded_peripheral(self): + tp = bp_backend.BluepyThreadedPeripheral('address', 'addrType', 'hci0') + self.assertEqual(tp._addr, 'address') + self.assertEqual(tp._addrType, 'addrType') + self.assertEqual(tp._iface_number, 0) + self.assertNotEqual(tp._dispatcher_thread, None) + + # Schedule some methods to async queue and give them some time to resolve + tp.set_notify_handler(lambda: '') + tp.write(123, 'qwe') + + tp._dispatcher_thread.join(1) + self.assertEqual(tp._dispatcher_thread.is_alive(), True) + tp.disconnect() + + tp._dispatcher_thread.join(2) + self.assertEqual(tp._dispatcher_thread.is_alive(), False) +