diff --git a/.gitignore b/.gitignore index bd88afc..4775f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea *.iml -*.pyc \ No newline at end of file +*.pyc +build \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index b93c167..e60ddcb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,14 @@ sudo: false language: python +virtualenv: + system_site_packages: true matrix: include: - os: linux python: 2.7 - os: linux - python: 3.5 - - os: linux - python: 3.6 + python: 3.4 addons: apt: @@ -16,10 +16,22 @@ addons: - libboost-python-dev - libboost-thread-dev - libbluetooth-dev + - libglib2.0-dev + - libdbus-1-dev + - libdbus-glib-1-dev + - libgirepository-1.0-1 + + - python-dbus + - python-gi + - python3-dbus + - python3-gi install: -- pip install codecov gattlib -script: coverage run --source=. `which nosetests` . --nocapture +- pip install codecov nose-exclude gattlib pygatt gatt pexpect + + +script: coverage run --source=. `which nosetests` tests --nocapture --exclude-dir=examples + after_success: - coverage report -m - codecov diff --git a/README.md b/README.md index e9f7e14..ba28853 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Python library to interact with Move Hub +from pylgbst.comms_gatt import GattConnection# Python library to interact with Move Hub _Move Hub is central controller block of [LEGO® Boost Robotics Set](https://www.lego.com/en-us/boost)._ @@ -6,7 +6,7 @@ In fact, Move Hub is just Bluetooth hardware, all manipulations are done with co Best way to start is to look into [`demo.py`](examples/demo.py) file, and run it (assuming you have installed library). -If you have Vernie assembled, you might run scripts from [`vernie`](examples/vernie/) directory. +If you have Vernie assembled, you might run scripts from [`examples/vernie`](examples/vernie/) directory. Demonstrational videos: @@ -29,17 +29,17 @@ Demonstrational videos: ## Usage -_Please note that it requires [gattlib](https://bitbucket.org/OscarAcena/pygattlib) to be installed, which is not supported on Windows._ +_Please note that this library requires one of Bluetooth backend libraries to be installed, please read section [here](#general-notes) for details_ Install library like this: ```bash -pip install https://github.com/undera/pylgbst/archive/0.5.tar.gz +pip install https://github.com/undera/pylgbst/archive/0.6.tar.gz ``` Then instantiate MoveHub object and start invoking its methods. Following is example to just print peripherals detected on Hub: ```python -from pylgbst import MoveHub +from pylgbst.movehub import MoveHub hub = MoveHub() @@ -67,7 +67,7 @@ All these methods are synchronous by default, means method does not return until An example: ```python -from pylgbst import MoveHub +from pylgbst.movehub import MoveHub import time hub = MoveHub() @@ -92,7 +92,7 @@ hub.motor_external.stop() Any motor allows to subscribe to its rotation sensor. Two sensor modes are available: rotation angle (`EncodedMotor.SENSOR_ANGLE`) and rotation speed (`EncodedMotor.SENSOR_SPEED`). Example: ```python -from pylgbst import MoveHub, EncodedMotor +from pylgbst.movehub import MoveHub, EncodedMotor import time def callback(angle): @@ -112,7 +112,7 @@ MoveHub's internal tilt sensor is available through `tilt_sensor` field. There a An example: ```python -from pylgbst import MoveHub, TiltSensor +from pylgbst.movehub import MoveHub, TiltSensor import time def callback(pitch, roll, yaw): @@ -159,7 +159,7 @@ Distance works in range of 0-10 inches, with ability to measure last inch in hig Simple example of subscribing to sensor: ```python -from pylgbst import MoveHub, ColorDistanceSensor +from pylgbst.movehub import MoveHub, ColorDistanceSensor import time def callback(clr, distance): @@ -194,7 +194,7 @@ You can obtain colors are present as constants `COLOR_*` and also a map of avail Additionally, you can subscribe to LED color change events, using callback function as shown in example below. ```python -from pylgbst import MoveHub, COLORS, COLOR_NONE, COLOR_RED +from pylgbst.movehub import MoveHub, COLORS, COLOR_NONE, COLOR_RED import time def callback(clr): @@ -221,7 +221,7 @@ Tip: blinking orange color of LED means battery is low. Note that `Button` class is not real `Peripheral`, as it has no port and not listed in `devices` field of Hub. Still, subscribing to button is done usual way: ```python -from pylgbst import MoveHub +from pylgbst.movehub import MoveHub def callback(is_pressed): print("Btn pressed: %s" % is_pressed) @@ -235,7 +235,7 @@ hub.button.subscribe(callback) `MoveHub` class has field `voltage` to subscribe to battery voltage status. Callback accepts single parameter with current value. The range of values is float between `0` and `1.0`. Every time data is received, value is also written into `last_value` field of `Voltage` object. Values less than `0.2` are known as lowest values, when unit turns off. ```python -from pylgbst import MoveHub +from pylgbst.movehub import MoveHub import time def callback(value): @@ -249,19 +249,56 @@ print ("Value: " % hub.voltage.last_value) ### General Notes -#### Bluetooth Connection -There is optional parameter for `MoveHub` class constructor, accepting instance of `Connection` object. By default, it uses instance of `BLEConnection` to connect directly to Move Hub. You can specify instance of `DebugServerConnection` if you are using Debug Server (more details below). +#### Bluetooth Backend Prerequisites -If you want to specify name for Bluetooth interface to use on local computer, create instance of `BLEConnection` and call `connect(if_name)` method of connection. Then pass it to `MoveHub` constructor. Like this: +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` + +_Please let author know if you have discovered any compatibility/preprequisite details, so we will update this section to help future users_ + +Depending on backend type, you might need Linux `sudo` to be used when running Python. + +#### Bluetooth Connection Options +There is optional parameter for `MoveHub` class constructor, accepting instance of `Connection` object. By default, it will try to use whatever `get_connection_auto()` returns. You have several options to manually control that: + +- use `pylgbst.get_connection_auto()` to attempt backend auto-choice, autodetect uses +- use `BlueGigaConnection()` - if you use BlueGiga Adapter (`pygatt` library prerequisite) +- use `GattConnection()` - if you use GattTool Backend on Linux (`gatt` library prerequisite) +- use `GattoolConnection()` - if you use GattTool Backend on Linux (`pygatt` library prerequisite) +- use `GattLibConnection()` - if you use GattTool Backend on Linux (`gattlib` 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. + +If you want to specify name for Bluetooth interface to use on local computer, you can passthat to class or function of getting a connection. Then pass connection object to `MoveHub` constructor. Like this: ```python -from pylgbst import BLEConnection, MoveHub +from pylgbst.movehub import MoveHub +from pylgbst.comms_gatt import GattConnection -conn = BLEConnection() -conn.connect("hci1") +conn = GattConnection("hci1") +conn.connect() # you can pass MoveHub mac address as parameter here, like 'AA:BB:CC:DD:EE:FF' hub = MoveHub(conn) ``` +#### Use Disconnect in `finally` + +It is recommended to make sure `disconnect()` method is called on connection object after you have finished your program. This ensures Bluetooth subsystem is cleared and avoids problems for subsequent re-connects of MoveHub. The best way to do that in Python is to use `try ... finally` clause: + +```python +from pylgbst import get_connection_auto +from pylgbst.movehub import MoveHub + +conn=get_connection_auto() # ! don't put this into `try` block +try: + hub = MoveHub(conn) +finally: + conn.disconnect() +``` + #### Devices Detecting As part of instantiating process, `MoveHub` waits up to 1 minute for all builtin devices to appear, such as motors on ports A and B, tilt sensor, button and battery. This not guarantees that external motor and/or color sensor will be present right after `MoveHub` instantiated. Usually, sleeping for couple of seconds gives it enough time to detect everything. @@ -274,17 +311,15 @@ It is possible to subscribe with multiple times for the same sensor. Only one, v Good practice for any program is to unsubscribe from all sensor subscriptions before ending, especially when used with `DebugServer`. - ## Debug Server Running debug server opens permanent BLE connection to Hub and listening on TCP port for communications. This avoids the need to re-start Hub all the time. There is `DebugServerConnection` class that you can use with it, instead of `BLEConnection`. -Starting debug server is done like this: +Starting debug server is done like this (you may need to run it with `sudo`, depending on your BLE backend): ```bash -sudo python -c "from pylgbst.comms import *; \ - import logging; logging.basicConfig(level=logging.DEBUG); \ - DebugServer(BLEConnection().connect()).start()" +python -c "import logging; logging.basicConfig(level=logging.DEBUG); \ + import pylgbst; pylgbst.start_debug_server()" ``` Then push green button on MoveHub, so permanent BLE connection will be established. diff --git a/examples/demo.py b/examples/demo.py index 54d34bc..b911592 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,9 +1,11 @@ # coding=utf-8 +import time from time import sleep from pylgbst import * from pylgbst.comms import DebugServerConnection -from pylgbst.movehub import MoveHub +from pylgbst.movehub import MoveHub, COLORS, COLOR_BLACK +from pylgbst.peripherals import EncodedMotor, TiltSensor, Amperage, Voltage log = logging.getLogger("demo") @@ -185,9 +187,12 @@ def demo_all(movehub): try: connection = DebugServerConnection() except BaseException: - logging.warning("Failed to use debug server: %s", traceback.format_exc()) - connection = BLEConnection().connect() + logging.debug("Failed to use debug server: %s", traceback.format_exc()) + connection = get_connection_auto() - hub = MoveHub(connection) - sleep(10000) - #demo_all(hub) + try: + hub = MoveHub(connection) + sleep(1) + # demo_all(hub) + finally: + connection.disconnect() diff --git a/examples/harmonograph/__init__.py b/examples/harmonograph/__init__.py index c08a73f..8106fcf 100644 --- a/examples/harmonograph/__init__.py +++ b/examples/harmonograph/__init__.py @@ -1,10 +1,9 @@ import logging import traceback -import time - -from pylgbst import MoveHub -from pylgbst.comms import DebugServerConnection, BLEConnection +from pylgbst import get_connection_auto +from pylgbst.comms import DebugServerConnection +from pylgbst.movehub import MoveHub if __name__ == '__main__': logging.basicConfig(level=logging.INFO) @@ -13,13 +12,13 @@ conn = DebugServerConnection() except BaseException: logging.warning("Failed to use debug server: %s", traceback.format_exc()) - conn = BLEConnection().connect() + conn = get_connection_auto() hub = MoveHub(conn) try: hub.motor_AB.constant(0.45, 0.45) hub.motor_external.angled(12590, 0.1) - #time.sleep(180) + # time.sleep(180) finally: hub.motor_AB.stop() if hub.motor_external: diff --git a/examples/plotter/__init__.py b/examples/plotter/__init__.py index ca7954b..b783176 100644 --- a/examples/plotter/__init__.py +++ b/examples/plotter/__init__.py @@ -2,7 +2,8 @@ import math import time -from pylgbst import ColorDistanceSensor, COLORS, COLOR_RED, COLOR_CYAN +from pylgbst.constants import COLOR_RED, COLOR_CYAN, COLORS +from pylgbst.peripherals import ColorDistanceSensor class Plotter(object): diff --git a/examples/plotter/try.py b/examples/plotter/try.py index b7bdad2..f0cd4f3 100644 --- a/examples/plotter/try.py +++ b/examples/plotter/try.py @@ -6,8 +6,9 @@ import six from examples.plotter import Plotter -from pylgbst import EncodedMotor, PORT_AB, PORT_C, PORT_A, PORT_B, MoveHub -from pylgbst.comms import DebugServerConnection, BLEConnection +from pylgbst import get_connection_auto +from pylgbst.comms import DebugServerConnection +from pylgbst.movehub import EncodedMotor, PORT_AB, PORT_C, PORT_A, PORT_B, MoveHub from tests import HubMock @@ -223,7 +224,7 @@ def interpret_command(cmd, plotter): conn = DebugServerConnection() except BaseException: logging.warning("Failed to use debug server: %s", traceback.format_exc()) - conn = BLEConnection().connect() + conn = get_connection_auto() hub = MoveHub(conn) if 1 else get_hub_mock() diff --git a/examples/sorter/__init__.py b/examples/sorter/__init__.py index 646053e..27ea73a 100644 --- a/examples/sorter/__init__.py +++ b/examples/sorter/__init__.py @@ -1,8 +1,10 @@ import logging import traceback -from pylgbst import MoveHub, COLORS, COLOR_RED, COLOR_YELLOW, COLOR_CYAN, COLOR_BLUE, COLOR_BLACK -from pylgbst.comms import DebugServerConnection, BLEConnection +from pylgbst import get_connection_auto +from pylgbst.comms import DebugServerConnection +from pylgbst.constants import COLORS, COLOR_YELLOW, COLOR_BLUE, COLOR_CYAN, COLOR_RED, COLOR_BLACK +from pylgbst.movehub import MoveHub class ColorSorter(MoveHub): @@ -82,7 +84,7 @@ def tick(self): conn = DebugServerConnection() except BaseException: logging.warning("Failed to use debug server: %s", traceback.format_exc()) - conn = BLEConnection().connect() + conn = get_connection_auto() sorter = ColorSorter(conn) empty = 0 diff --git a/examples/vernie/__init__.py b/examples/vernie/__init__.py index b249f24..0b83f0b 100644 --- a/examples/vernie/__init__.py +++ b/examples/vernie/__init__.py @@ -3,9 +3,11 @@ import os import re import subprocess +import time from pylgbst import * from pylgbst.comms import DebugServerConnection +from pylgbst.movehub import MoveHub try: import gtts @@ -41,7 +43,8 @@ def say(text): "commands help": "Available commands are: " "forward, backward, turn left, turn right, " "head left, head right, head straight, shot and say", - "finished": "Thank you! Robot is now turning off" + "finished": "Thank you! Robot is now turning off", + "text is empty": "Please, enter not empty text to say!" }, "ru": { "ready": "Робот Веернии готов к работе", @@ -49,9 +52,10 @@ def say(text): "ok": "хорошо", "commands help": "Доступные команды это: вперёд, назад, влево, вправо, " "голову влево, голову вправо, голову прямо, выстрел, скажи", - "Finished": "Робот завершает работу. Спасибо!", + "finished": "Робот завершает работу. Спасибо!", "commands from file": "Исполняю команды из файла", "fire": "Выстрел!", + "text is empty": "Пожалуйста, наберите не пустой текст!" } } @@ -64,8 +68,8 @@ def __init__(self, language='en'): try: conn = DebugServerConnection() except BaseException: - logging.debug("Failed to use debug server: %s", traceback.format_exc()) - conn = BLEConnection().connect() + logging.warning("Failed to use debug server: %s", traceback.format_exc()) + conn = get_connection_auto() super(Vernie, self).__init__(conn) self.language = language @@ -132,6 +136,9 @@ def interpret_command(self, cmd, confirm): confirm(cmd) self.head(STRAIGHT) elif cmd[0] in ("say", "скажи", "сказать"): + if not cmd[1:]: + self.say("text is empty") + return say(' '.join(cmd[1:])) elif cmd[0] in ("fire", "shot", "огонь", "выстрел"): say("fire") diff --git a/examples/vernie/go_towards_light.py b/examples/vernie/go_towards_light.py index ffc8562..c90e454 100644 --- a/examples/vernie/go_towards_light.py +++ b/examples/vernie/go_towards_light.py @@ -1,3 +1,4 @@ +from pylgbst.peripherals import ColorDistanceSensor from . import * logging.basicConfig(level=logging.INFO) diff --git a/examples/vernie/run_away_game.py b/examples/vernie/run_away_game.py index c269cf3..3ec0a7c 100644 --- a/examples/vernie/run_away_game.py +++ b/examples/vernie/run_away_game.py @@ -1,3 +1,4 @@ +from pylgbst.constants import COLOR_GREEN, COLOR_NONE from . import * robot = Vernie() diff --git a/examples/vernie/run_commands_file.py b/examples/vernie/run_commands_file.py index 44091f4..3bf45a8 100644 --- a/examples/vernie/run_commands_file.py +++ b/examples/vernie/run_commands_file.py @@ -1,3 +1,5 @@ +import sys + from . import * robot = Vernie() @@ -9,7 +11,7 @@ def confirmation(command): robot.say(command[0]) -with open("vernie.commands") as fhd: +with open(os.path.join(os.path.dirname(__file__), "vernie.commands")) as fhd: for cmd in fhd.readlines(): sys.stdout.write("%s" % cmd) robot.interpret_command(cmd, confirmation) diff --git a/pylgbst/__init__.py b/pylgbst/__init__.py index 6d0e618..c2276e0 100644 --- a/pylgbst/__init__.py +++ b/pylgbst/__init__.py @@ -1,2 +1,62 @@ -from pylgbst.movehub import * -from pylgbst.peripherals import * +import logging +import traceback + +from pylgbst.comms import DebugServer + +log = logging.getLogger('pylgbst') + + +def get_connection_bluegiga(mac): + from pylgbst.comms_pygatt import BlueGigaConnection + + return BlueGigaConnection().connect(mac) + + +def get_connection_gattool(controller='hci0', hub_mac=None): + from pylgbst.comms_pygatt import GattoolConnection + + return GattoolConnection(controller).connect(hub_mac) + + +def get_connection_gatt(controller='hci0', hub_mac=None): + from pylgbst.comms_gatt import GattConnection + + return GattConnection(controller).connect(hub_mac) + + +def get_connection_gattlib(controller='hci0', hub_mac=None): + from pylgbst.comms_gattlib import GattLibConnection + + return GattLibConnection(controller).connect(hub_mac) + + +def get_connection_auto(controller='hci0', hub_mac=None): + conn = None + try: + return get_connection_bluegiga(hub_mac) + except BaseException: + logging.debug("Failed: %s", traceback.format_exc()) + try: + conn = get_connection_gatt(controller, hub_mac) + except BaseException: + logging.debug("Failed: %s", traceback.format_exc()) + + try: + conn = get_connection_gattool(controller, hub_mac) + except BaseException: + logging.debug("Failed: %s", traceback.format_exc()) + + try: + conn = get_connection_gattlib(controller, hub_mac) + except BaseException: + logging.debug("Failed: %s", traceback.format_exc()) + + if conn is None: + raise Exception("Failed to autodetect connection, make sure you have installed prerequisites") + + return conn + + +def start_debug_server(iface="hci0", port=9090): + server = DebugServer(get_connection_auto(iface)) + server.start(port) diff --git a/pylgbst/comms.py b/pylgbst/comms.py index a84955c..567c0a9 100644 --- a/pylgbst/comms.py +++ b/pylgbst/comms.py @@ -8,54 +8,23 @@ import traceback from abc import abstractmethod from binascii import unhexlify -from gattlib import DiscoveryService, GATTRequester from threading import Thread -from pylgbst.constants import MSG_DEVICE_SHUTDOWN, queue, str2hex +from pylgbst.constants import MSG_DEVICE_SHUTDOWN, ENABLE_NOTIFICATIONS_HANDLE, ENABLE_NOTIFICATIONS_VALUE +from pylgbst.utilities import str2hex log = logging.getLogger('comms') LEGO_MOVE_HUB = "LEGO Move Hub" -# noinspection PyMethodOverriding -class Requester(GATTRequester): - """ - Wrapper to access `on_notification` capability of GATT - Set "notification_sink" field to a callable that will handle incoming data - """ - - def __init__(self, p_object, *args, **kwargs): - super(Requester, self).__init__(p_object, *args, **kwargs) - self.notification_sink = None - - self._notify_queue = queue.Queue() # this queue is to minimize time spent in gattlib C code - thr = Thread(target=self._dispatch_notifications) - thr.setDaemon(True) - thr.setName("Notify queue dispatcher") - thr.start() - - def on_notification(self, handle, data): - # log.debug("requester notified, sink: %s", self.notification_sink) - self._notify_queue.put((handle, data)) - - def on_indication(self, handle, data): - log.debug("Indication on handle %s: %s", handle, str2hex(data)) - - def _dispatch_notifications(self): - while True: - handle, data = self._notify_queue.get() - if self.notification_sink: - try: - self.notification_sink(handle, data) - except BaseException: - log.warning("Data was: %s", str2hex(data)) - log.warning("Failed to dispatch notification: %s", traceback.format_exc()) - else: - log.warning("Dropped notification %s: %s", handle, str2hex(data)) +class Connection(object): + def connect(self, hub_mac=None): + pass + def disconnect(self): + pass -class Connection(object): @abstractmethod def write(self, handle, data): pass @@ -64,48 +33,8 @@ def write(self, handle, data): def set_notify_handler(self, handler): pass - -class BLEConnection(Connection): - """ - Main transport class, uses real Bluetooth LE connection. - Loops with timeout of 1 seconds to find device named "Lego MOVE Hub" - - :type requester: Requester - """ - - def __init__(self): - super(BLEConnection, self).__init__() - self.requester = None - - def connect(self, bt_iface_name='hci0', hub_mac=None): - service = DiscoveryService(bt_iface_name) - - while not self.requester: - log.info("Discovering devices using %s...", bt_iface_name) - devices = service.discover(1) - log.debug("Devices: %s", devices) - - for address, name in devices.items(): - if name == LEGO_MOVE_HUB or hub_mac == address: - logging.info("Found %s at %s", name, address) - self.requester = Requester(address, True, bt_iface_name) - break - - if self.requester: - break - - return self - - def set_notify_handler(self, handler): - if self.requester: - log.debug("Setting notification handler: %s", handler) - self.requester.notification_sink = handler - else: - raise RuntimeError("No requester available") - - def write(self, handle, data): - log.debug("Writing to %s: %s", handle, str2hex(data)) - return self.requester.write_by_handle(handle, data) + def enable_notifications(self): + self.write(ENABLE_NOTIFICATIONS_HANDLE, ENABLE_NOTIFICATIONS_VALUE) class DebugServer(object): @@ -114,13 +43,13 @@ class DebugServer(object): It holds BLE connection to Move Hub, so no need to re-start it every time Usage: DebugServer(BLEConnection().connect()).start() - :type ble: BLEConnection + :type connection: BLEConnection """ - def __init__(self, ble_trans): + def __init__(self, connection): self._running = False self.sock = socket.socket() - self.ble = ble_trans + self.connection = connection def start(self, port=9090): self.sock.bind(('', port)) @@ -128,11 +57,11 @@ def start(self, port=9090): self._running = True while self._running: - log.info("Accepting connections at %s", port) + log.info("Accepting MoveHub debug connections at %s", port) conn, addr = self.sock.accept() if not self._running: raise KeyboardInterrupt("Shutdown") - self.ble.requester.notification_sink = lambda x, y: self._notify(conn, x, y) + self.connection.requester.notification_sink = lambda x, y: self._notify(conn, x, y) try: self._handle_conn(conn) except KeyboardInterrupt: @@ -140,7 +69,7 @@ def start(self, port=9090): except BaseException: log.error("Problem handling incoming connection: %s", traceback.format_exc()) finally: - self.ble.requester.notification_sink = self._notify_dummy + self.connection.requester.notification_sink = self._notify_dummy conn.close() def __del__(self): @@ -195,7 +124,7 @@ def _handle_conn(self, conn): def _handle_cmd(self, cmd): if cmd['type'] == 'write': - self.ble.write(cmd['handle'], unhexlify(cmd['data'])) + self.connection.write(cmd['handle'], unhexlify(cmd['data'])) else: raise ValueError("Unhandled cmd: %s", cmd) @@ -259,10 +188,3 @@ def _recv(self): def set_notify_handler(self, handler): self.notify_handler = handler - - -def start_debug_server(iface="hci0", port=9090): - ble = BLEConnection() - ble.connect(iface) - server = DebugServer(ble) - server.start(port) diff --git a/pylgbst/comms_gatt.py b/pylgbst/comms_gatt.py new file mode 100644 index 0000000..a808ffe --- /dev/null +++ b/pylgbst/comms_gatt.py @@ -0,0 +1,120 @@ +import logging +import re +import threading +from time import sleep + +import gatt + +from pylgbst.comms import Connection, LEGO_MOVE_HUB +from pylgbst.constants import MOVE_HUB_HW_UUID_SERV, MOVE_HUB_HW_UUID_CHAR, MOVE_HUB_HARDWARE_HANDLE +from pylgbst.utilities import str2hex + +log = logging.getLogger('comms-gatt') + + +class CustomDevice(gatt.Device, object): + def __init__(self, mac_address, manager): + gatt.Device.__init__(self, mac_address=mac_address, manager=manager) + self._notify_callback = lambda hnd, val: None + self._handle = None + + def connect(self): + gatt.Device.connect(self) + log.info("Waiting for device connection...") + while self._handle is None: + log.debug("Sleeping...") + sleep(1) + + if isinstance(self._handle, BaseException): + exc = self._handle + self._handle = None + raise exc + + def write(self, data): + log.debug("Writing to handle: %s", str2hex(data)) + return self._handle.write_value(data) + + def enable_notifications(self): + log.debug('Enable Notifications...') + self._handle.enable_notifications() + + def set_notific_handler(self, func_hnd): + self._notify_callback = func_hnd + + def services_resolved(self): + log.debug('Getting MoveHub services and characteristics...') + gatt.Device.services_resolved(self) + log.debug("[%s] Resolved services", self.mac_address) + for service in self.services: + log.debug("[%s] Service [%s]", self.mac_address, service.uuid) + for characteristic in service.characteristics: + log.debug("[%s] Characteristic [%s]", self.mac_address, characteristic.uuid) + if service.uuid == MOVE_HUB_HW_UUID_SERV and characteristic.uuid == MOVE_HUB_HW_UUID_CHAR: + log.debug('MoveHub characteristic found') + self._handle = characteristic + + if self._handle is None: + self.manager.stop() + self._handle = RuntimeError("Failed to obtain MoveHub handle") + + def characteristic_value_updated(self, characteristic, value): + value = self._fix_weird_bug(value) + log.debug('Notification in GattDevice: %s', str2hex(value)) + self._notify_callback(MOVE_HUB_HARDWARE_HANDLE, value) + + def _fix_weird_bug(self, value): + if isinstance(value, str) and "dbus.Array" in value: # weird bug from gatt on my Ubuntu 16.04! + log.debug("Fixing broken notify string: %s", value) + return ''.join([chr(int(x.group(1))) for x in re.finditer(r"dbus.Byte\((\d+)\)", value)]) + + return value + + +class GattConnection(Connection): + """ + :type _device: CustomDevice + """ + + def __init__(self, bt_iface_name='hci0'): + super(GattConnection, self).__init__() + self._device = None + self._iface = bt_iface_name + + def connect(self, hub_mac=None): + dev_manager = gatt.DeviceManager(adapter_name=self._iface) + dman_thread = threading.Thread(target=dev_manager.run) + dman_thread.setDaemon(True) + log.debug('Starting DeviceManager...') + dman_thread.start() + dev_manager.start_discovery() + + while not self._device: + log.info("Discovering devices...") + devices = dev_manager.devices() + log.debug("Devices: %s", devices) + + for dev in devices: + address = dev.mac_address + name = dev.alias() + if name == LEGO_MOVE_HUB or hub_mac == address: + logging.info("Found %s at %s", name, address) + self._device = CustomDevice(address, dev_manager) + break + + if not self._device: + sleep(1) + + self._device.connect() + return self + + def disconnect(self): + self._device.disconnect() + + def write(self, handle, data): + self._device.write(data) + + def set_notify_handler(self, handler): + self._device.set_notific_handler(handler) + + def enable_notifications(self): + self._device.enable_notifications() diff --git a/pylgbst/comms_gattlib.py b/pylgbst/comms_gattlib.py new file mode 100644 index 0000000..8f69548 --- /dev/null +++ b/pylgbst/comms_gattlib.py @@ -0,0 +1,91 @@ +# noinspection PyMethodOverriding +import logging +import traceback +from gattlib import DiscoveryService, GATTRequester +from threading import Thread + +from pylgbst.comms import Connection, LEGO_MOVE_HUB +from pylgbst.utilities import queue, str2hex + +log = logging.getLogger('comms-gattlib') + + +class Requester(GATTRequester): + """ + Wrapper to access `on_notification` capability of GATT + Set "notification_sink" field to a callable that will handle incoming data + """ + + def __init__(self, p_object, *args, **kwargs): + super(Requester, self).__init__(p_object, *args, **kwargs) + self.notification_sink = None + + self._notify_queue = queue.Queue() # this queue is to minimize time spent in gattlib C code + thr = Thread(target=self._dispatch_notifications) + thr.setDaemon(True) + thr.setName("Notify queue dispatcher") + thr.start() + + def on_notification(self, handle, data): + # log.debug("requester notified, sink: %s", self.notification_sink) + self._notify_queue.put((handle, data)) + + def on_indication(self, handle, data): + log.debug("Indication on handle %s: %s", handle, str2hex(data)) + + def _dispatch_notifications(self): + while True: + handle, data = self._notify_queue.get() + data = data[3:] # for some reason, there are extra bytes + if self.notification_sink: + try: + self.notification_sink(handle, data) + except BaseException: + log.warning("Data was: %s", str2hex(data)) + log.warning("Failed to dispatch notification: %s", traceback.format_exc()) + else: + log.warning("Dropped notification %s: %s", handle, str2hex(data)) + + +class GattLibConnection(Connection): + """ + Main transport class, uses real Bluetooth LE connection. + Loops with timeout of 1 seconds to find device named "Lego MOVE Hub" + + :type requester: Requester + """ + + def __init__(self, bt_iface_name='hci0'): + super(GattLibConnection, self).__init__() + self.requester = None + self._iface = bt_iface_name + + def connect(self, hub_mac=None): + service = DiscoveryService(self._iface) + + while not self.requester: + log.info("Discovering devices using %s...", self._iface) + devices = service.discover(1) + log.debug("Devices: %s", devices) + + for address, name in devices.items(): + if name == LEGO_MOVE_HUB or hub_mac == address: + logging.info("Found %s at %s", name, address) + self.requester = Requester(address, True, self._iface) + break + + if self.requester: + break + + return self + + def set_notify_handler(self, handler): + if self.requester: + log.debug("Setting notification handler: %s", handler) + self.requester.notification_sink = handler + else: + raise RuntimeError("No requester available") + + def write(self, handle, data): + log.debug("Writing to %s: %s", handle, str2hex(data)) + return self.requester.write_by_handle(handle, data) diff --git a/pylgbst/comms_pygatt.py b/pylgbst/comms_pygatt.py new file mode 100644 index 0000000..ca11f0b --- /dev/null +++ b/pylgbst/comms_pygatt.py @@ -0,0 +1,61 @@ +import logging + +import pygatt + +from pylgbst.comms import Connection, LEGO_MOVE_HUB +from pylgbst.constants import MOVE_HUB_HW_UUID_CHAR +from pylgbst.utilities import str2hex + +log = logging.getLogger('comms-pygatt') + + +class GattoolConnection(Connection): + """ + Used for connecting to + + :type _conn_hnd: pygatt.backends.bgapi.device.BGAPIBLEDevice + """ + + def __init__(self, controller='hci0'): + Connection.__init__(self) + self.backend = lambda: pygatt.GATTToolBackend(hci_device=controller) + self._conn_hnd = None + + def connect(self, hub_mac=None): + log.debug("Trying to connect client to MoveHub with MAC: %s", hub_mac) + adapter = self.backend() + adapter.start() + + while not self._conn_hnd: + log.info("Discovering devices...") + devices = adapter.scan(1) + log.debug("Devices: %s", devices) + + for dev in devices: + address = dev['address'] + name = dev['name'] + if name == LEGO_MOVE_HUB or hub_mac == address: + logging.info("Found %s at %s", name, address) + self._conn_hnd = adapter.connect(address) + break + + if self._conn_hnd: + break + + return self + + def disconnect(self): + self._conn_hnd.disconnect() + + def write(self, handle, data): + log.debug("Writing to handle %s: %s", handle, str2hex(data)) + return self._conn_hnd.char_write_handle(handle, bytearray(data)) + + def set_notify_handler(self, handler): + self._conn_hnd.subscribe(MOVE_HUB_HW_UUID_CHAR, handler) + + +class BlueGigaConnection(GattoolConnection): + def __init__(self): + super(BlueGigaConnection, self).__init__() + self.backend = lambda: pygatt.GATTToolBackend() diff --git a/pylgbst/constants.py b/pylgbst/constants.py index 86ff01f..44c0432 100644 --- a/pylgbst/constants.py +++ b/pylgbst/constants.py @@ -1,32 +1,12 @@ -import binascii -import struct -import sys - -if sys.version_info[0] == 2: - import Queue as queue -else: - import queue as queue - -queue = queue # just to use it - - -def str2hex(data): - return binascii.hexlify(data).decode("utf8") - - -def usbyte(seq, index): - return struct.unpack("