diff --git a/pygatt/gatttool_classes.py b/pygatt/gatttool_classes.py new file mode 100644 index 00000000..d5ab79ab --- /dev/null +++ b/pygatt/gatttool_classes.py @@ -0,0 +1,330 @@ +import logging +import logging.handlers +import string +import time +import threading + +from collections import defaultdict + +import pexpect + +import pygatt_constants +import pygatt_exceptions +# import gatttool_util + + +"""pygatt Class Definitions""" + +__author__ = 'Greg Albrecht ' +__license__ = 'Apache License, Version 2.0' +__copyright__ = 'Copyright 2015 Orion Labs' + + +class GATTToolBackend(object): + logger = logging.getLogger(__name__) + logger.setLevel(pygatt_constants.LOG_LEVEL) + console_handler = logging.StreamHandler() + console_handler.setLevel(pygatt_constants.LOG_LEVEL) + formatter = logging.Formatter(pygatt_constants.LOG_FORMAT) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + GATTTOOL_PROMPT = r".*> " + + def __init__(self, mac_address, hci_device='hci0', logfile=None): + self.handles = {} + self.subscribed_handlers = {} + self.address = mac_address + + self.running = True + + self.lock = threading.Lock() + + self.connection_lock = threading.RLock() + + gatttool_cmd = ' '.join([ + 'gatttool', + '-b', + self.address, + '-i', + hci_device, + '-I' + ]) + + self.logger.debug('gatttool_cmd=%s', gatttool_cmd) + self.con = pexpect.spawn(gatttool_cmd, logfile=logfile) + + self.con.expect(r'\[LE\]>', timeout=1) + + self.callbacks = defaultdict(set) + + self.thread = threading.Thread(target=self.run) + self.thread.daemon = True + self.thread.start() + + def bond(self): + """Securely Bonds to the BLE device.""" + self.logger.info('Bonding') + self.con.sendline('sec-level medium') + self.con.expect(self.GATTTOOL_PROMPT, timeout=1) + + def connect(self, + timeout=pygatt_constants.DEFAULT_CONNECT_TIMEOUT_S): + """Connect to the device.""" + self.logger.info('Connecting with timeout=%s', timeout) + try: + with self.connection_lock: + self.con.sendline('connect') + self.con.expect(r'Connection successful.*\[LE\]>', timeout) + except pexpect.TIMEOUT: + message = ("Timed out connecting to %s after %s seconds." + % (self.address, timeout)) + self.logger.error(message) + raise pygatt_exceptions.NotConnectedError(message) + + def get_handle(self, uuid): + """ + Look up and return the handle for an attribute by its UUID. + :param uuid: The UUID of the characteristic. + :type uuid: str + :return: None if the UUID was not found. + """ + if uuid not in self.handles: + self.logger.debug("Looking up handle for characteristic %s", uuid) + with self.connection_lock: + self.con.sendline('characteristics') + + timeout = 2 + while True: + try: + self.con.expect( + r"handle: 0x([a-fA-F0-9]{4}), " + "char properties: 0x[a-fA-F0-9]{2}, " + "char value handle: 0x[a-fA-F0-9]{4}, " + "uuid: ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\r\n", # noqa + timeout=timeout) + except pexpect.TIMEOUT: + break + except pexpect.EOF: + break + else: + try: + handle = int(self.con.match.group(1), 16) + char_uuid = self.con.match.group(2).strip() + self.handles[char_uuid] = handle + self.logger.debug( + "Found characteristic %s, handle: %d", uuid, + handle) + + # The characteristics all print at once, so after + # waiting 1-2 seconds for them to all fetch, you can + # load the rest without much delay at all. + timeout = .01 + except AttributeError: + pass + handle = self.handles.get(uuid) + if handle is None: + message = "No characteristic found matching %s" % uuid + self.logger.warn(message) + raise pygatt_exceptions.BluetoothLEError(message) + + self.logger.debug( + "Characteristic %s, handle: %d", uuid, handle) + return handle + + def _expect(self, expected, + timeout=pygatt_constants.DEFAULT_TIMEOUT_S): + """ + We may (and often do) get an indication/notification before a + write completes, and so it can be lost if we "expect()"'d something + that came after it in the output, e.g.: + > char-write-req 0x1 0x2 + Notification handle: xxx + Write completed successfully. + > + Anytime we expect something we have to expect noti/indication first for + a short time. + """ + with self.connection_lock: + patterns = [ + expected, + 'Notification handle = .*? \r', + 'Indication handle = .*? \r', + '.*Invalid file descriptor.*', + '.*Disconnected\r', + ] + while True: + try: + matched_pattern_index = self.con.expect(patterns, timeout) + if matched_pattern_index == 0: + break + elif matched_pattern_index in [1, 2]: + self._handle_notification(self.con.after) + elif matched_pattern_index in [3, 4]: + if self.running: + message = ("Unexpectedly disconnected - do you " + "need to clear bonds?") + self.logger.error(message) + self.running = False + raise pygatt_exceptions.NotConnectedError() + except pexpect.TIMEOUT: + raise pygatt_exceptions.NotificationTimeout( + "Timed out waiting for a notification") + + def char_write(self, handle, value, wait_for_response=False): + """ + Writes a value to a given characteristic handle. + :param handle: + :param value: + :param wait_for_response: + """ + with self.connection_lock: + hexstring = ''.join('%02x' % byte for byte in value) + + if wait_for_response: + cmd = 'req' + else: + cmd = 'cmd' + + cmd = 'char-write-%s 0x%02x %s' % (cmd, handle, hexstring) + + self.logger.debug('Sending cmd=%s', cmd) + self.con.sendline(cmd) + + if wait_for_response: + try: + self._expect('Characteristic value written successfully') + except pygatt_exceptions.NoResponseError: + self.logger.error("No response received", exc_info=True) + raise + + self.logger.info('Sent cmd=%s', cmd) + + def char_read_uuid(self, uuid): + """ + Reads a Characteristic by UUID. + :param uuid: UUID of Characteristic to read. + :type uuid: str + :return: bytearray of result. + :rtype: bytearray + """ + with self.connection_lock: + self.con.sendline('char-read-uuid %s' % uuid) + self._expect('value: .*? \r') + + rval = self.con.after.split()[1:] + + return bytearray([int(x, 16) for x in rval]) + + def char_read_hnd(self, handle): + """ + Reads a Characteristic by Handle. + :param handle: Handle of Characteristic to read. + :type handle: str + :return: + :rtype: + """ + with self.connection_lock: + self.con.sendline('char-read-hnd 0x%02x' % handle) + self._expect('descriptor: .*?\r') + + rval = self.con.after.split()[1:] + + return [int(n, 16) for n in rval] + + def subscribe(self, uuid, callback=None, indication=False): + """ + Enables subscription to a Characteristic with ability to call callback. + :param uuid: + :param callback: + :param indication: + :return: + :rtype: + """ + self.logger.info( + 'Subscribing to uuid=%s with callback=%s and indication=%s', + uuid, callback, indication) + definition_handle = self.get_handle(uuid) + # Expect notifications on the value handle... + value_handle = definition_handle + 1 + # but write to the characteristic config to enable notifications + characteristic_config_handle = value_handle + 1 + + if indication: + properties = bytearray([0x02, 0x00]) + else: + properties = bytearray([0x01, 0x00]) + + try: + self.lock.acquire() + + if callback is not None: + self.callbacks[value_handle].add(callback) + + if self.subscribed_handlers.get(value_handle, None) != properties: + self.char_write( + characteristic_config_handle, + properties, + wait_for_response=False + ) + self.logger.debug("Subscribed to uuid=%s", uuid) + self.subscribed_handlers[value_handle] = properties + else: + self.logger.debug("Already subscribed to uuid=%s", uuid) + finally: + self.lock.release() + + def _handle_notification(self, msg): + """ + Receive a notification from the connected device and propagate the value + to all registered callbacks. + """ + hex_handle, _, hex_value = string.split(msg.strip(), maxsplit=5)[3:] + handle = int(hex_handle, 16) + value = bytearray.fromhex(hex_value) + + self.logger.info('Received notification on handle=%s, value=%s', + hex_handle, hex_value) + try: + self.lock.acquire() + + if handle in self.callbacks: + for callback in self.callbacks[handle]: + callback(handle, value) + finally: + self.lock.release() + + def stop(self): + """ + Stop the backgroud notification handler in preparation for a + disconnect. + """ + self.logger.info('Stopping') + self.running = False + + if self.con.isalive(): + self.con.sendline('exit') + while True: + if not self.con.isalive(): + break + time.sleep(0.1) + self.con.close() + + def run(self): + """ + Run a background thread to listen for notifications. + """ + self.logger.info('Running...') + while self.running: + with self.connection_lock: + try: + self._expect("fooooooo", timeout=.1) + except pygatt_exceptions.NotificationTimeout: + pass + except (pygatt_exceptions.NotConnectedError, pexpect.EOF): + break + # TODO need some delay to avoid aggresively grabbing the lock, + # blocking out the others. worst case is 1 second delay for async + # not received as a part of another request + time.sleep(.01) + self.logger.info("Listener thread finished") diff --git a/pygatt/gatttool_util.py b/pygatt/gatttool_util.py new file mode 100644 index 00000000..91a6e962 --- /dev/null +++ b/pygatt/gatttool_util.py @@ -0,0 +1,91 @@ +import re +import subprocess +import pexpect +import logging + +from pygatt_exceptions import BluetoothLEError + + +""" +Utils for pygatt Module. +""" + +__author__ = 'Greg Albrecht ' +__license__ = 'Apache License, Version 2.0' +__copyright__ = 'Copyright 2015 Orion Labs' + + +logger = logging.getLogger(__name__) + + +# TODO(gba): Replace with Fabric. +def reset_bluetooth_controller(hci_device='hci0'): + """ + Re-initializses Bluetooth Controller Interface. + This is accomplished by bringing down and up the interface. + :param interface: Interface to re-initialize. + :type interface: str + """ + subprocess.Popen(["sudo", "systemctl", "restart", "bluetooth"]).wait() + subprocess.Popen(["sudo", "hciconfig", hci_device, "reset"]).wait() + + +# TODO(gba): Replace with Fabric. +def lescan(timeout=5, use_sudo=True): + """ + Performs a BLE scan using hcitool. + If you don't want to use 'sudo', you must add a few 'capabilities' to your + system. If you have libcap installed, run this to enable normal users to + perform LE scanning: + setcap 'cap_net_raw,cap_net_admin+eip' `which hcitool` + If you do use sudo, the hcitool subprocess becomes more difficult to + terminate cleanly, and may leave your Bluetooth adapter in a bad state. + :param timeout: Time (in seconds) to wait for the scan to complete. + :param use_sudo: Perform scan as superuser. + :type timeout: int + :type use_sudo: bool + :return: List of BLE devices found. + :rtype: list + """ + cmd = 'hcitool lescan' + if use_sudo: + cmd = 'sudo %s' % cmd + + logger.info("Starting BLE scan") + scan = pexpect.spawn(cmd) + + # "lescan" doesn't exit, so we're forcing a timeout here: + try: + scan.expect('foooooo', timeout=timeout) + except pexpect.EOF: + message = "Unexpected error when scanning" + if "No such device" in scan.before: + message = "No BLE adapter found" + logger.error(message) + raise BluetoothLEError(message) + except pexpect.TIMEOUT: + devices = {} + for line in scan.before.split('\r\n'): + match = re.match( + r'(([0-9A-Fa-f][0-9A-Fa-f]:?){6}) (\(?[\w]+\)?)', line) + + if match is not None: + address = match.group(1) + name = match.group(3) + if name == "(unknown)": + name = None + + if address in devices: + if devices[address]['name'] is None and name is not None: + logger.info("Discovered name of %s as %s", + address, name) + devices[address]['name'] = name + else: + logger.info("Discovered %s (%s)", address, name) + devices[address] = { + 'address': address, + 'name': name + } + logger.info("Found %d BLE devices", len(devices)) + return [device for device in devices.values()] + return [] diff --git a/pygatt/pygatt.py b/pygatt/pygatt.py index 0b8a4e22..a283d3b0 100644 --- a/pygatt/pygatt.py +++ b/pygatt/pygatt.py @@ -1,12 +1,11 @@ from __future__ import print_function -# from collections import defaultdict from binascii import unhexlify import logging -# import string import time from bled112_backend import BLED112Backend +from gatttool_classes import GATTToolBackend from pygatt_constants import( BACKEND, DEFAULT_CONNECT_TIMEOUT_S, LOG_LEVEL, LOG_FORMAT ) @@ -26,9 +25,11 @@ def __init__(self, mac_address, backend=BACKEND['GATTTOOL'], logfile=None, the following format: "XX:XX:XX:XX:XX:XX" backend -- backend to use. One of pygatt.constants.backend. logfile -- the file in which to write the logs. - serial_port -- the serial port to which the BLED112 is connected. - delete_backend_bonds -- delete the bonds stored on the backend so that - bonding does not inadvertently take place. + serial_port -- (BLED112 only) the serial port to which the BLED112 is + connected. + delete_backend_bonds -- (BLED112 only) delete the bonds stored on the + backend so that bonding does not inadvertently + take place. """ # Initialize self._backend_type = None @@ -61,7 +62,9 @@ def __init__(self, mac_address, backend=BACKEND['GATTTOOL'], logfile=None, if delete_backend_bonds: self._backend.delete_stored_bonds() elif backend == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._logger.info("pygatt[GATTTOOL]") + # TODO: hci_device, how to pass logfile + self._backend = GATTToolBackend(mac_address, hci_device='hci1') else: raise ValueError("backend", backend) self._backend_type = backend @@ -74,7 +77,7 @@ def bond(self): if self._backend_type == BACKEND['BLED112']: self._backend.bond() elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._backend.bond() else: raise NotImplementedError("backend", self._backend_type) @@ -92,7 +95,8 @@ def connect(self, timeout=DEFAULT_CONNECT_TIMEOUT_S): if self._backend_type == BACKEND['BLED112']: return self._backend.connect(self._mac_address, timeout=timeout) elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._backend.connect(timeout=timeout) + return True else: raise NotImplementedError("backend", self._backend_type) @@ -112,7 +116,7 @@ def char_read(self, uuid): return None return self._backend.char_read(handle) elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._backend.char_read_uuid(uuid) else: raise NotImplementedError("backend", self._backend_type) @@ -124,10 +128,10 @@ def char_write(self, uuid_write, value, wait_for_response=False, uuid -- the UUID of the characteristic to write to. value -- the value as a bytearray to write to the characteristic. wait_for_response -- wait for notifications/indications after writing. - num_packets -- the number of notification/indication packets to wait - for. - uuid_recv -- the UUID for the characteritic that will send the - notification/indication packets. + num_packets -- (BLED112 only) the number of notification/indication BLE + packets to wait for. + uuid_recv -- (BLED112 only) the UUID for the characteritic that will + send the notification/indication packets. Returns True on success. Returns False otherwise. @@ -165,7 +169,9 @@ def char_write(self, uuid_write, value, wait_for_response=False, cb(bytearray(value_list)) return True elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + handle = self._backend.get_handle(uuid_write) + self._backend.char_write(handle, value, + wait_for_response=wait_for_response) else: raise NotImplementedError("backend", self._backend_type) @@ -177,27 +183,28 @@ def encrypt(self): if self._backend_type == BACKEND['BLED112']: self._backend.encrypt() elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + raise NotImplementedError("pygatt[GATTOOL].encrypt") else: raise NotImplementedError("backend", self._backend_type) def exit(self): """ - Cleans up. Run this when done using the BluetoothLEDevice object. + Cleans up. Run this when done using the BluetoothLEDevice object with + the BLED112 backend (BLED112 only). """ self._logger.info("exit") if self._backend_type == BACKEND['BLED112']: self._backend.disconnect() self._backend.stop() elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + pass else: raise NotImplementedError("backend", self._backend_type) def get_rssi(self): """ Get the receiver signal strength indicator (RSSI) value from the BLE - device. + device (BLED112 only). Returns the RSSI value on success. Returns None on failure. @@ -212,33 +219,34 @@ def get_rssi(self): return rssi time.sleep(0.1) elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + raise NotImplementedError("pygatt[GATTOOL].get_rssi") else: raise NotImplementedError("backend", self._backend_type) def run(self): """ - Run a background thread to listen for notifications. + Run a background thread to listen for notifications (GATTTOOL only). """ self._logger.info("run") if self._backend_type == BACKEND['BLED112']: # Nothing to do pass elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._backend.run() else: raise NotImplementedError("backend", self._backend_type) def stop(self): """ - Stop the backgroud notification handler in preparation for a disconnect. + Stop the backgroud notification handler in preparation for a disconnect + (GATTTOOL only). """ self._logger.info("stop") if self._backend_type == BACKEND['BLED112']: # Nothing to do pass elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._backend.stop() else: raise NotImplementedError("backend", self._backend_type) @@ -262,7 +270,8 @@ def subscribe(self, uuid, callback=None, indication=False): self._callbacks[uuid] = [] self._callbacks[uuid].append(callback) elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + self._backend.subscribe(uuid, callback=callback, + indication=indication) else: raise NotImplementedError("backend", self._backend_type) @@ -277,7 +286,7 @@ def _get_handle(self, uuid): if self._backend_type == BACKEND['BLED112']: return self._backend.get_handle(uuid) elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") + return self._backend.get_handle(uuid) else: raise NotImplementedError("backend", self._backend_type) @@ -292,36 +301,3 @@ def _uuid_bytearray(self, uuid): """ self._logger.info("_uuid_bytearray %s", uuid) return unhexlify(uuid.replace("-", "")) - -# FIXME going to use these? - def _expect(self, expected): # timeout=pygatt.constants.DEFAULT_TIMEOUT_S): - """We may (and often do) get an indication/notification before a - write completes, and so it can be lost if we "expect()"'d something - that came after it in the output, e.g.: - - > char-write-req 0x1 0x2 - Notification handle: xxx - Write completed successfully. - > - - Anytime we expect something we have to expect noti/indication first for - a short time. - """ - if self._backend_type == BACKEND['BLED112']: - raise NotImplementedError("backend", self._backend_type) - elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") - else: - raise NotImplementedError("backend", self._backend_type) - - def _handle_notification(self, msg): - """ - Receive a notification from the connected device and propagate the value - to all registered callbacks. - """ - if self._backend_type == BACKEND['BLED112']: - raise NotImplementedError("backend", self._backend_type) - elif self._backend_type == BACKEND['GATTTOOL']: - raise NotImplementedError("TODO") - else: - raise NotImplementedError("backend", self._backend_type) diff --git a/pygatt/pygatt_exceptions.py b/pygatt/pygatt_exceptions.py new file mode 100644 index 00000000..200029ac --- /dev/null +++ b/pygatt/pygatt_exceptions.py @@ -0,0 +1,25 @@ +""" +Exceptions for pygatt Module. +""" + +__author__ = 'Greg Albrecht ' +__license__ = 'Apache License, Version 2.0' +__copyright__ = 'Copyright 2015 Orion Labs' + + +class BluetoothLEError(Exception): + """Exception class for pygatt.""" + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self.message) + + +class NotConnectedError(BluetoothLEError): + pass + + +class NotificationTimeout(BluetoothLEError): + pass + + +class NoResponseError(BluetoothLEError): + pass diff --git a/tox.ini b/tox.ini index dd048d1d..cd8e172c 100644 --- a/tox.ini +++ b/tox.ini @@ -17,4 +17,4 @@ passenv = PROGRAMFILES [flake8] max-line-length = 80 -exclude=.coverage,.eggs,.git,.gitignore,.tox,.travis.yml,Include,LICENSE,Lib,MANIFEST.in,Makefile,NOTICE, README.rst,Scripts,build,cover,dist,docs,man, nostests.xm., pip-selfcheck.json,pygatt.egg-info,requirements.txt,setup.py,setup.cfg,tox.ini +exclude=.coverage,.eggs,.git,.gitignore,.tox,.travis.yml,coverage*, nose*,include,Include,LICENSE,lib,local,Lib,MANIFEST.in,Makefile,NOTICE, README.rst,bin,Scripts,build,cover,dist,docs,man, nostests.xm., pip-selfcheck.json,pygatt.egg-info,requirements.txt,setup.py,setup.cfg,tox.ini