├── .gitignore ├── AUTHOR ├── LICENSE ├── README.md ├── ble_scan_wipy.py ├── bluetooth_utils.py ├── example_ble_advertise.py └── example_ble_scan.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /AUTHOR: -------------------------------------------------------------------------------- 1 | Author: 2 | Colin Guyon 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Colin GUYON 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py-bluetooth-utils 2 | 3 | Python module containing bluetooth utility functions, in particular 4 | for easy BLE scanning and advertising 5 | 6 | It either uses HCI commands using PyBluez, or does ioctl calls like it's 7 | done in Bluez tools such as hciconfig. 8 | 9 | Main functions: 10 | - ``toggle_device`` : enable or disable a bluetooth device 11 | - ``set_scan`` : set scan type on a device ("noscan", "iscan", "pscan", "piscan") 12 | - ``enable/disable_le_scan`` : enable BLE scanning 13 | - ``parse_le_advertising_events`` : parse BLE advertisements packets 14 | - ``start/stop_le_advertising`` : advertise custom data using BLE 15 | 16 | Bluez : http://www.bluez.org/ 17 | PyBluez : https://github.com/pybluez/pybluez 18 | 19 | The module was in particular inspired from 'iBeacon-Scanner-' 20 | (https://github.com/switchdoclabs/iBeacon-Scanner-/blob/master/blescan.py) 21 | and sometimes directly from the Bluez sources. 22 | -------------------------------------------------------------------------------- /ble_scan_wipy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple BLE forever-scan example, that prints all the detected 3 | LE advertisement packets, and prints a colored diff of data on data changes. 4 | """ 5 | import sys 6 | import struct 7 | import bluetooth._bluetooth as bluez 8 | 9 | from bluetooth_utils import (toggle_device, 10 | enable_le_scan, parse_le_advertising_events, 11 | disable_le_scan, raw_packet_to_str) 12 | 13 | dev_id = 1 # the bluetooth device is hci0 14 | toggle_device(dev_id, True) 15 | 16 | try: 17 | sock = bluez.hci_open_dev(dev_id) 18 | except: 19 | print("Cannot open bluetooth device %i" % dev_id) 20 | raise 21 | 22 | enable_le_scan(sock, filter_duplicates=False) 23 | 24 | import pynput.keyboard 25 | keyboard = pynput.keyboard.Controller() 26 | KEY_VOLUME_DOWN = pynput.keyboard.KeyCode.from_vk(0x1008ff11) 27 | KEY_VOLUME_UP = pynput.keyboard.KeyCode.from_vk(0x1008ff13) 28 | 29 | try: 30 | prev_data = None 31 | prev_rot_val = 0 32 | 33 | def le_advertise_packet_handler(mac, data, rssi): 34 | global prev_data 35 | global prev_rot_val 36 | print() 37 | print("packet len is", len(data)) 38 | data_str = raw_packet_to_str(data) 39 | data_wo_rssi = (mac, data_str) 40 | print("BLE packet: %s %s %d" % (mac, data_str, rssi)) 41 | if prev_data is not None: 42 | if data_wo_rssi != prev_data: 43 | # color differences with previous packet data 44 | sys.stdout.write(' ' * 20 + 'data_diff=') 45 | for c1, c2 in zip(data_str, prev_data[1]): 46 | if c1 != c2: 47 | sys.stdout.write('\033[0;33m' + c1 + '\033[m') 48 | else: 49 | sys.stdout.write(c1) 50 | sys.stdout.write('\n') 51 | 52 | 53 | # Advertisement are split into: 54 | # " 55 | # (types are listed here: https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile) 56 | 57 | pkt_start = 4 58 | pkt_data_start = pkt_start + 2 59 | pkt_data_len = data[pkt_start] - 1 60 | assert data[pkt_start + 1] == 0x09 # Type should be "Complete Local Name" 61 | print("name is %r" % data[pkt_data_start:pkt_data_start + pkt_data_len].decode('utf-8')) 62 | 63 | pkt_start = pkt_data_start + pkt_data_len 64 | pkt_data_start = pkt_start + 2 65 | pkt_data_len = data[pkt_start] - 1 66 | assert data[pkt_start + 1] == 0xFF # Type should be "Manufacturer Specific Data" 67 | print("manufacturer data len is", pkt_data_len) 68 | my_data = data[pkt_data_start:pkt_data_start + pkt_data_len] 69 | my_data = struct.unpack('B' * pkt_data_len, my_data) 70 | print("manufacturer data is", my_data) 71 | if my_data[2] == 1: 72 | key = pynput.keyboard.Key.right 73 | key = KEY_VOLUME_UP 74 | keyboard.press(key); keyboard.release(key) 75 | elif my_data[2] == 2: 76 | key = pynput.keyboard.Key.left 77 | key = KEY_VOLUME_DOWN 78 | keyboard.press(key); keyboard.release(key) 79 | prev_rot_val = my_data[1] 80 | 81 | prev_data = data_wo_rssi 82 | 83 | # Blocking call (the given handler will be called each time a new LE 84 | # advertisement packet is detected) 85 | parse_le_advertising_events(sock, 86 | # mac_addr=['24:0A:C4:00:A9:72'], # wipy2 87 | mac_addr=['E1:1B:2B:AC:1F:F9'], # ble nano v2 88 | handler=le_advertise_packet_handler, 89 | debug=False) 90 | except KeyboardInterrupt: 91 | disable_le_scan(sock) 92 | -------------------------------------------------------------------------------- /bluetooth_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Module containing some bluetooth utility functions (linux only). 4 | 5 | It either uses HCI commands using PyBluez, or does ioctl calls like it's 6 | done in Bluez tools such as hciconfig. 7 | 8 | Main functions: 9 | - toggle_device : enable or disable a bluetooth device 10 | - set_scan : set scan type on a device ("noscan", "iscan", "pscan", "piscan") 11 | - enable/disable_le_scan : enable BLE scanning 12 | - parse_le_advertising_events : parse and read BLE advertisements packets 13 | - start/stop_le_advertising : advertise custom data using BLE 14 | 15 | Bluez : http://www.bluez.org/ 16 | PyBluez : http://karulis.github.io/pybluez/ 17 | 18 | The module was in particular inspired from 'iBeacon-Scanner-' 19 | https://github.com/switchdoclabs/iBeacon-Scanner-/blob/master/blescan.py 20 | and sometimes directly from the Bluez sources. 21 | """ 22 | 23 | from __future__ import absolute_import 24 | import sys 25 | import struct 26 | import fcntl 27 | import array 28 | import socket 29 | from errno import EALREADY 30 | 31 | # import PyBluez 32 | import bluetooth._bluetooth as bluez 33 | 34 | __all__ = ('toggle_device', 'set_scan', 35 | 'enable_le_scan', 'disable_le_scan', 'parse_le_advertising_events', 36 | 'start_le_advertising', 'stop_le_advertising', 37 | 'raw_packet_to_str') 38 | 39 | LE_META_EVENT = 0x3E 40 | LE_PUBLIC_ADDRESS = 0x00 41 | LE_RANDOM_ADDRESS = 0x01 42 | 43 | OGF_LE_CTL = 0x08 44 | OCF_LE_SET_SCAN_PARAMETERS = 0x000B 45 | OCF_LE_SET_SCAN_ENABLE = 0x000C 46 | OCF_LE_CREATE_CONN = 0x000D 47 | OCF_LE_SET_ADVERTISING_PARAMETERS = 0x0006 48 | OCF_LE_SET_ADVERTISE_ENABLE = 0x000A 49 | OCF_LE_SET_ADVERTISING_DATA = 0x0008 50 | 51 | SCAN_TYPE_PASSIVE = 0x00 52 | SCAN_FILTER_DUPLICATES = 0x01 53 | SCAN_DISABLE = 0x00 54 | SCAN_ENABLE = 0x01 55 | 56 | # sub-events of LE_META_EVENT 57 | EVT_LE_CONN_COMPLETE = 0x01 58 | EVT_LE_ADVERTISING_REPORT = 0x02 59 | EVT_LE_CONN_UPDATE_COMPLETE = 0x03 60 | EVT_LE_READ_REMOTE_USED_FEATURES_COMPLETE = 0x04 61 | 62 | # Advertisement event types 63 | ADV_IND = 0x00 64 | ADV_DIRECT_IND = 0x01 65 | ADV_SCAN_IND = 0x02 66 | ADV_NONCONN_IND = 0x03 67 | ADV_SCAN_RSP = 0x04 68 | 69 | # Allow Scan Request from Any, Connect Request from Any 70 | FILTER_POLICY_NO_WHITELIST = 0x00 71 | # Allow Scan Request from White List Only, Connect Request from Any 72 | FILTER_POLICY_SCAN_WHITELIST = 0x01 73 | # Allow Scan Request from Any, Connect Request from White List Only 74 | FILTER_POLICY_CONN_WHITELIST = 0x02 75 | # Allow Scan Request from White List Only, Connect Request from White List Only 76 | FILTER_POLICY_SCAN_AND_CONN_WHITELIST = 0x03 77 | 78 | 79 | def toggle_device(dev_id, enable): 80 | """ 81 | Power ON or OFF a bluetooth device. 82 | 83 | :param dev_id: Device id. 84 | :type dev_id: ``int`` 85 | :param enable: Whether to enable of disable the device. 86 | :type enable: ``bool`` 87 | """ 88 | hci_sock = socket.socket(socket.AF_BLUETOOTH, 89 | socket.SOCK_RAW, 90 | socket.BTPROTO_HCI) 91 | print("Power %s bluetooth device %d" % ('ON' if enable else 'OFF', dev_id)) 92 | # di = struct.pack("HbBIBBIIIHHHH10I", dev_id, *((0,) * 22)) 93 | # fcntl.ioctl(hci_sock.fileno(), bluez.HCIGETDEVINFO, di) 94 | req_str = struct.pack("H", dev_id) 95 | request = array.array("b", req_str) 96 | try: 97 | fcntl.ioctl(hci_sock.fileno(), 98 | bluez.HCIDEVUP if enable else bluez.HCIDEVDOWN, 99 | request[0]) 100 | except IOError as e: 101 | if e.errno == EALREADY: 102 | print("Bluetooth device %d is already %s" % ( 103 | dev_id, 'enabled' if enable else 'disabled')) 104 | else: 105 | raise 106 | finally: 107 | hci_sock.close() 108 | 109 | 110 | # Types of bluetooth scan 111 | SCAN_DISABLED = 0x00 112 | SCAN_INQUIRY = 0x01 113 | SCAN_PAGE = 0x02 114 | 115 | 116 | def set_scan(dev_id, scan_type): 117 | """ 118 | Set scan type on a given bluetooth device. 119 | 120 | :param dev_id: Device id. 121 | :type dev_id: ``int`` 122 | :param scan_type: One of 123 | ``'noscan'`` 124 | ``'iscan'`` 125 | ``'pscan'`` 126 | ``'piscan'`` 127 | :type scan_type: ``str`` 128 | """ 129 | hci_sock = socket.socket(socket.AF_BLUETOOTH, 130 | socket.SOCK_RAW, 131 | socket.BTPROTO_HCI) 132 | if scan_type == "noscan": 133 | dev_opt = SCAN_DISABLED 134 | elif scan_type == "iscan": 135 | dev_opt = SCAN_INQUIRY 136 | elif scan_type == "pscan": 137 | dev_opt = SCAN_PAGE 138 | elif scan_type == "piscan": 139 | dev_opt = SCAN_PAGE | SCAN_INQUIRY 140 | else: 141 | raise ValueError("Unknown scan type %r" % scan_type) 142 | 143 | req_str = struct.pack("HI", dev_id, dev_opt) 144 | print("Set scan type %r to bluetooth device %d" % (scan_type, dev_id)) 145 | try: 146 | fcntl.ioctl(hci_sock.fileno(), bluez.HCISETSCAN, req_str) 147 | finally: 148 | hci_sock.close() 149 | 150 | 151 | def raw_packet_to_str(pkt): 152 | """ 153 | Returns the string representation of a raw HCI packet. 154 | """ 155 | if sys.version_info > (3, 0): 156 | return ''.join('%02x' % struct.unpack("B", bytes([x]))[0] for x in pkt) 157 | else: 158 | return ''.join('%02x' % struct.unpack("B", x)[0] for x in pkt) 159 | 160 | 161 | def enable_le_scan(sock, interval=0x0800, window=0x0800, 162 | filter_policy=FILTER_POLICY_NO_WHITELIST, 163 | filter_duplicates=True): 164 | """ 165 | Enable LE passive scan (with filtering of duplicate packets enabled). 166 | 167 | :param sock: A bluetooth HCI socket (retrieved using the 168 | ``hci_open_dev`` PyBluez function). 169 | :param interval: Scan interval. 170 | :param window: Scan window (must be less or equal than given interval). 171 | :param filter_policy: One of 172 | ``FILTER_POLICY_NO_WHITELIST`` (default value) 173 | ``FILTER_POLICY_SCAN_WHITELIST`` 174 | ``FILTER_POLICY_CONN_WHITELIST`` 175 | ``FILTER_POLICY_SCAN_AND_CONN_WHITELIST`` 176 | 177 | .. note:: Scan interval and window are to multiply by 0.625 ms to 178 | get the real time duration. 179 | """ 180 | print("Enable LE scan") 181 | own_bdaddr_type = LE_PUBLIC_ADDRESS # does not work with LE_RANDOM_ADDRESS 182 | cmd_pkt = struct.pack(" 31: 241 | raise ValueError("data is too long (%d but max is 31 bytes)", 242 | data_length) 243 | cmd_pkt = struct.pack("