Skip to content

Commit dc1c388

Browse files
karatheodoryundera
authored andcommitted
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.
1 parent f078d18 commit dc1c388

File tree

6 files changed

+200
-1
lines changed

6 files changed

+200
-1
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ addons:
2727
- python3-dbus
2828
- python3-gi
2929
install:
30-
- pip install codecov nose-exclude gattlib pygatt gatt pexpect
30+
- pip install codecov nose-exclude gattlib pygatt gatt pexpect bluepy
3131

3232

3333
script: coverage run --source=. `which nosetests` tests --nocapture --exclude-dir=examples

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ You have following options to install as Bluetooth backend:
257257
- `pip install pygatt` - [pygatt](https://github.com/peplin/pygatt) lib, works on both Windows and Linux
258258
- `pip install gatt` - [gatt](https://github.com/getsenic/gatt-python) lib, supports Linux, does not work on Windows
259259
- `pip install gattlib` - [gattlib](https://bitbucket.org/OscarAcena/pygattlib) - supports Linux, does not work on Windows, requires `sudo`
260+
- `pip install bluepy` - [bluepy](https://github.com/IanHarvey/bluepy) lib, supports Linux, including Raspbian, which allows connection to the hub from the Raspberry PI
260261

261262
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.
262263

@@ -272,6 +273,7 @@ There is optional parameter for `MoveHub` class constructor, accepting instance
272273
- use `GattConnection()` - if you use Gatt Backend on Linux (`gatt` library prerequisite)
273274
- use `GattoolConnection()` - if you use GattTool Backend on Linux (`pygatt` library prerequisite)
274275
- use `GattLibConnection()` - if you use GattLib Backend on Linux (`gattlib` library prerequisite)
276+
- use `BluepyConnection()` - if you use Bluepy backend on Linux/Raspbian (`bluepy` library prerequisite)
275277
- pass instance of `DebugServerConnection` if you are using [Debug Server](#debug-server) (more details below).
276278

277279
All the functions above have optional arguments to specify adapter name and MoveHub mac address. Please look function source code for details.

pylgbst/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@ def get_connection_gattlib(controller='hci0', hub_mac=None):
2929

3030
return GattLibConnection(controller).connect(hub_mac)
3131

32+
def get_connection_bluepy(controller='hci0', hub_mac=None):
33+
from pylgbst.comms.cbluepy import BluepyConnection
34+
35+
return BluepyConnection(controller).connect(hub_mac)
36+
3237

3338
def get_connection_auto(controller='hci0', hub_mac=None):
3439
fns = [
40+
get_connection_bluepy,
3541
get_connection_bluegiga,
3642
get_connection_gatt,
3743
get_connection_gattool,

pylgbst/comms/cbluepy.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import re
2+
import logging
3+
from threading import Thread, Event
4+
import time
5+
from contextlib import contextmanager
6+
from enum import Enum
7+
8+
from bluepy import btle
9+
10+
from pylgbst.comms import Connection, LEGO_MOVE_HUB
11+
from pylgbst.constants import MOVE_HUB_HW_UUID_CHAR
12+
from pylgbst.utilities import str2hex, queue
13+
14+
log = logging.getLogger('comms-bluepy')
15+
16+
COMPLETE_LOCAL_NAME_ADTYPE = 9
17+
PROPAGATE_DISPATCHER_EXCEPTION = False
18+
19+
20+
def _get_iface_number(controller):
21+
"""bluepy uses iface numbers instead of full names."""
22+
if not controller:
23+
return None
24+
m = re.search(r'hci(\d+)$', controller)
25+
if not m:
26+
raise ValueError('Cannot find iface number in {}.'.format(controller))
27+
return int(m.group(1))
28+
29+
30+
class BluepyDelegate(btle.DefaultDelegate):
31+
def __init__(self, handler):
32+
btle.DefaultDelegate.__init__(self)
33+
34+
self._handler = handler
35+
36+
def handleNotification(self, cHandle, data):
37+
log.debug('Incoming notification')
38+
self._handler(cHandle, data)
39+
40+
41+
# We need a separate thread to wait for notifications,
42+
# but calling peripheral's methods from different threads creates issues,
43+
# so we will wrap all the calls into a thread
44+
class BluepyThreadedPeripheral(object):
45+
def __init__(self, addr, addrType, controller):
46+
self._call_queue = queue.Queue()
47+
self._addr = addr
48+
self._addrType = addrType
49+
self._iface_number = _get_iface_number(controller)
50+
51+
self._disconnect_event = Event()
52+
53+
self._dispatcher_thread = Thread(target=self._dispatch_calls)
54+
self._dispatcher_thread.setDaemon(True)
55+
self._dispatcher_thread.setName("Bluepy call dispatcher")
56+
self._dispatcher_thread.start()
57+
58+
def _dispatch_calls(self):
59+
self._peripheral = btle.Peripheral(self._addr, self._addrType, self._iface_number)
60+
try:
61+
while not self._disconnect_event.is_set():
62+
try:
63+
try:
64+
method = self._call_queue.get(False)
65+
method()
66+
except queue.Empty:
67+
pass
68+
self._peripheral.waitForNotifications(1.)
69+
except Exception as ex:
70+
log.exception('Exception in call dispatcher thread', exc_info=ex)
71+
if PROPAGATE_DISPATCHER_EXCEPTION:
72+
log.error("Terminating dispatcher thread.")
73+
raise
74+
finally:
75+
self._peripheral.disconnect()
76+
77+
78+
def write(self, handle, data):
79+
self._call_queue.put(lambda: self._peripheral.writeCharacteristic(handle, data))
80+
81+
def set_notify_handler(self, handler):
82+
delegate = BluepyDelegate(handler)
83+
self._call_queue.put(lambda: self._peripheral.withDelegate(delegate))
84+
85+
def disconnect(self):
86+
self._disconnect_event.set()
87+
88+
89+
class BluepyConnection(Connection):
90+
def __init__(self, controller='hci0'):
91+
Connection.__init__(self)
92+
self._peripheral = None # :type BluepyThreadedPeripheral
93+
self._controller = controller
94+
95+
def connect(self, hub_mac=None):
96+
log.debug("Trying to connect client to MoveHub with MAC: %s", hub_mac)
97+
scanner = btle.Scanner()
98+
99+
while not self._peripheral:
100+
log.info("Discovering devices...")
101+
scanner.scan(1)
102+
devices = scanner.getDevices()
103+
104+
for dev in devices:
105+
address = dev.addr
106+
addressType = dev.addrType
107+
name = dev.getValueText(COMPLETE_LOCAL_NAME_ADTYPE)
108+
log.debug("Found dev, name: {}, address: {}".format(name, address))
109+
110+
if (not hub_mac and name == LEGO_MOVE_HUB) or hub_mac == address:
111+
logging.info("Found %s at %s", name, address)
112+
self._peripheral = BluepyThreadedPeripheral(address, addressType, self._controller)
113+
break
114+
115+
return self
116+
117+
def disconnect(self):
118+
self._peripheral.disconnect()
119+
120+
def write(self, handle, data):
121+
log.debug("Writing to handle %s: %s", handle, str2hex(data))
122+
self._peripheral.write(handle, data)
123+
124+
def set_notify_handler(self, handler):
125+
self._peripheral.set_notify_handler(handler)
126+
127+
def is_alive(self):
128+
return True
129+

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
'gatt': ["gatt"],
1212
'gattlib': ["gattlib"],
1313
'pygatt': ["pygatt"],
14+
'bluepy': ["bluepy"],
1415
}
1516
)

tests/test_cbluepy.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import unittest
2+
import time
3+
4+
import pylgbst.comms.cbluepy as bp_backend
5+
6+
7+
class PeripheralMock(object):
8+
def __init__(self, addr, addrType, ifaceNumber):
9+
pass
10+
11+
def waitForNotifications(self, timeout):
12+
pass
13+
14+
def writeCharacteristic(self, handle, data):
15+
pass
16+
17+
def withDelegate(self, delegate):
18+
pass
19+
20+
def disconnect(self):
21+
pass
22+
23+
bp_backend.PROPAGATE_DISPATCHER_EXCEPTION = True
24+
bp_backend.btle.Peripheral = lambda *args, **kwargs: PeripheralMock(*args, **kwargs)
25+
26+
27+
class BluepyTestCase(unittest.TestCase):
28+
def test_get_iface_number(self):
29+
self.assertEqual(bp_backend._get_iface_number('hci0'), 0)
30+
self.assertEqual(bp_backend._get_iface_number('hci10'), 10)
31+
try:
32+
bp_backend._get_iface_number('ads12')
33+
self.fail('Missing exception for incorrect value')
34+
except ValueError:
35+
pass
36+
37+
def test_delegate(self):
38+
def _handler(handle, data):
39+
_handler.called = True
40+
delegate = bp_backend.BluepyDelegate(_handler)
41+
delegate.handleNotification(123, 'qwe')
42+
self.assertEqual(_handler.called, True)
43+
44+
def test_threaded_peripheral(self):
45+
tp = bp_backend.BluepyThreadedPeripheral('address', 'addrType', 'hci0')
46+
self.assertEqual(tp._addr, 'address')
47+
self.assertEqual(tp._addrType, 'addrType')
48+
self.assertEqual(tp._iface_number, 0)
49+
self.assertNotEqual(tp._dispatcher_thread, None)
50+
51+
# Schedule some methods to async queue and give them some time to resolve
52+
tp.set_notify_handler(lambda: '')
53+
tp.write(123, 'qwe')
54+
55+
tp._dispatcher_thread.join(1)
56+
self.assertEqual(tp._dispatcher_thread.is_alive(), True)
57+
tp.disconnect()
58+
59+
tp._dispatcher_thread.join(2)
60+
self.assertEqual(tp._dispatcher_thread.is_alive(), False)
61+

0 commit comments

Comments
 (0)