├── MANIFEST.in ├── requirements.txt ├── pybluetooth ├── exceptions.py ├── version.py ├── hci_errors.py ├── address.py ├── synchronous.py ├── hci_event_mask.py ├── connection.py ├── pyusb_bt_sockets.py └── __init__.py ├── tests ├── test_hci_event_mask.py ├── test_hcithread.py ├── test_scapy_bt_packets.py ├── test_address.py ├── test_rxthread.py └── test_connection_manager.py ├── examples ├── scan.py └── connect.py ├── .gitignore ├── LICENSE ├── setup.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.md 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | scapy==2.3.2.dev0 2 | dnet==1.12 3 | pcapy==0.10.10 4 | pyusb==1.0.0b2 5 | six==1.10.0 6 | pbr==1.9.1 7 | enum34==1.1.3 8 | -------------------------------------------------------------------------------- /pybluetooth/exceptions.py: -------------------------------------------------------------------------------- 1 | class TimeoutException(Exception): 2 | pass 3 | 4 | class NotYetImplementedException(Exception): 5 | pass 6 | -------------------------------------------------------------------------------- /pybluetooth/version.py: -------------------------------------------------------------------------------- 1 | __author__ = 'martijnthe' 2 | 3 | __version_info__ = (0, 0, 3) 4 | __version__ = '.'.join(map(str, __version_info__)) 5 | -------------------------------------------------------------------------------- /tests/test_hci_event_mask.py: -------------------------------------------------------------------------------- 1 | from pybluetooth import hci_event_mask 2 | 3 | 4 | def test_hci_event_mask_all_enabled(): 5 | m = hci_event_mask.all_enabled() 6 | assert(m == 0x3dbff807fffb9fff) 7 | 8 | m_bytes = hci_event_mask.all_enabled_str() 9 | assert(m_bytes == "\xff\x9f\xfb\xff\x07\xf8\xbf\x3d") 10 | 11 | 12 | def test_hci_event_mask_to_little_endian_bytes(): 13 | m_bytes = hci_event_mask.to_little_endian_bytes(0) 14 | assert(m_bytes == "\x00\x00\x00\x00\x00\x00\x00\x00") 15 | 16 | m_bytes = hci_event_mask.to_little_endian_bytes( 17 | 0x1122334455667788) 18 | assert(m_bytes == "\x88\x77\x66\x55\x44\x33\x22\x11") 19 | -------------------------------------------------------------------------------- /examples/scan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import logging 4 | 5 | from pybluetooth import BTStack 6 | from pybluetooth.synchronous import BTStackSynchronousUtils 7 | 8 | 9 | LOG = logging.getLogger("pybluetooth") 10 | 11 | LOG.setLevel(logging.DEBUG) 12 | lsh = logging.StreamHandler() 13 | formatter = logging.Formatter('%(asctime)s> %(message)s') 14 | lsh.setFormatter(formatter) 15 | LOG.addHandler(lsh) 16 | 17 | b = BTStack() 18 | b.start() 19 | 20 | u = BTStackSynchronousUtils(b) 21 | 22 | # Scan for a couple seconds, then print out the found reports: 23 | reports = u.scan(2) 24 | for report in reports: 25 | report.show() 26 | 27 | # Tear down the stack: 28 | b.quit() 29 | -------------------------------------------------------------------------------- /tests/test_hcithread.py: -------------------------------------------------------------------------------- 1 | from pybluetooth import HCIThread 2 | from pybluetooth.address import * 3 | 4 | from scapy.layers.bluetooth import * 5 | 6 | try: 7 | from unittest.mock import MagicMock 8 | except ImportError: 9 | from mock import MagicMock 10 | 11 | 12 | def _create_patched_hci_thread(): 13 | h = HCIThread(MagicMock()) 14 | h.sent_packets = [] 15 | 16 | def send_cmd(scapy_packet, *args, **kwargs): 17 | h.sent_packets.append(scapy_packet) 18 | h.send_cmd = send_cmd 19 | return h 20 | 21 | 22 | def test_hci_thread_cmd_le_create_connection(): 23 | h = _create_patched_hci_thread() 24 | a = Address("c9:ea:a5:b8:c8:1", AddressType.random) 25 | h.cmd_le_create_connection(a) 26 | p = h.sent_packets[0] 27 | assert p.getlayer(HCI_Cmd_LE_Create_Connection) 28 | assert p.patype == 0x01 # Random 29 | assert p.paddr == "c9:ea:a5:b8:c8:01" 30 | -------------------------------------------------------------------------------- /.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 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 pebble 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 | -------------------------------------------------------------------------------- /tests/test_scapy_bt_packets.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import scapy 3 | 4 | from scapy.layers.bluetooth import * 5 | from scapy.packet import Packet 6 | 7 | 8 | scapy.config.conf.debug_dissector = 1 9 | 10 | 11 | def test_hci_advertising_report_event_ad_data(): 12 | raw_data = binascii.unhexlify( 13 | "043e2b020100016522c00181781f0201020303d9fe1409" 14 | "506562626c652054696d65204c452037314536020a0cde") 15 | packet = HCI_Hdr(raw_data) 16 | 17 | assert(packet[EIR_Flags].flags == 0x02) 18 | assert(packet[EIR_CompleteList16BitServiceUUIDs].svc_uuids == [0xfed9]) 19 | assert(packet[EIR_CompleteLocalName].local_name == 'Pebble Time LE 71E6') 20 | assert(packet[EIR_TX_Power_Level].level == 12) 21 | 22 | 23 | def test_hci_advertising_report_event_scan_resp(): 24 | raw_data = binascii.unhexlify( 25 | "043e2302010401be5e0eb9f04f1716ff5401005f423331" 26 | "3134374432343631fc00030c0000de") 27 | packet = HCI_Hdr(raw_data) 28 | 29 | raw_mfg_data = '\x00_B31147D2461\xfc\x00\x03\x0c\x00\x00' 30 | assert(packet[EIR_Manufacturer_Specific_Data].data == raw_mfg_data) 31 | assert(packet[EIR_Manufacturer_Specific_Data].company_id == 0x154) 32 | -------------------------------------------------------------------------------- /tests/test_address.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | from scapy.layers.bluetooth import * 4 | from scapy.utils import mac2str, str2mac 5 | from pybluetooth.address import * 6 | 7 | 8 | def test_address_from_hci_le_connection_complete_packet(): 9 | raw_data = binascii.unhexlify( 10 | "043e1301004800000110c8b8a5eac9380000002a0000") 11 | packet = HCI_Hdr(raw_data) 12 | 13 | address = Address.from_packet(packet) 14 | assert str2mac(address.bd_addr) == "c9:ea:a5:b8:c8:10" 15 | assert address.macstr() == "c9:ea:a5:b8:c8:10" 16 | assert address.address_type == AddressType.random 17 | assert address.is_random() 18 | assert not address.is_public() 19 | 20 | def test_address_from_advertising_report_packet(): 21 | raw_data = binascii.unhexlify( 22 | "043e280201000110c8b8a5eac91c0201060303d9fe" 23 | "1109506562626c652054696d652043383130020a00d5") 24 | packet = HCI_Hdr(raw_data) 25 | 26 | address = Address.from_packet(packet) 27 | assert str2mac(address.bd_addr) == "c9:ea:a5:b8:c8:10" 28 | assert address.macstr() == "c9:ea:a5:b8:c8:10" 29 | assert address.address_type == AddressType.random 30 | assert address.is_random() 31 | assert not address.is_public() 32 | 33 | -------------------------------------------------------------------------------- /pybluetooth/hci_errors.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class HCIErrorCode(Enum): 5 | success = 0x00 6 | unknown_hci_command = 0x01 7 | unknown_connection_id = 0x02 8 | hardware_failure = 0x03 9 | page_timeout = 0x04 10 | authentication_failure = 0x05 11 | pin_or_key_missing = 0x06 12 | memory_capacity_exceeded = 0x07 13 | connection_timeout = 0x08 14 | connection_limit_exceeded = 0x09 15 | synchronous_connection_limit_exceeded = 0x0a 16 | acl_connection_already_exists = 0x0b 17 | command_disallowed = 0x0c 18 | connection_rejected_due_to_limited_resources = 0x0d 19 | connection_rejected_due_to_bd_addr = 0x0e 20 | 21 | connection_accept_timeout_exceeded = 0x10 22 | unsupported_feature_or_parameter_value = 0x11 23 | invalid_hci_command_parameters = 0x12 24 | remote_user_terminated_connection = 0x13 25 | remote_device_terminated_connection_due_to_low_resources = 0x14 26 | remote_device_terminated_connection_due_to_power_off = 0x15 27 | connection_terminated_by_local_host = 0x16 28 | 29 | lmp_response_timeout = 0x22 30 | 31 | instant_passed = 0x28 32 | 33 | connection_terminated_due_to_mic_failure = 0x3d 34 | connection_failed_to_be_established = 0x3e 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /examples/connect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import logging 4 | import pybluetooth 5 | import time 6 | 7 | from pybluetooth import BTStack 8 | from pybluetooth.address import * 9 | from pybluetooth.synchronous import BTStackSynchronousUtils 10 | 11 | 12 | LOG = logging.getLogger("pybluetooth") 13 | 14 | LOG.setLevel(logging.DEBUG) 15 | lsh = logging.StreamHandler() 16 | formatter = logging.Formatter('%(asctime)s> %(message)s') 17 | lsh.setFormatter(formatter) 18 | LOG.addHandler(lsh) 19 | 20 | b = BTStack() 21 | b.start() 22 | 23 | u = BTStackSynchronousUtils(b) 24 | 25 | # Attempt to connect to a device by address: 26 | try: 27 | connection = u.connect(Address("c9:ea:a5:b8:c8:10"), timeout=5.0) 28 | # Stay connected for 15 seconds, 29 | time.sleep(15) 30 | # ... then disconnect: 31 | u.disconnect(connection) 32 | except pybluetooth.exceptions.TimeoutException as e: 33 | LOG.debug("Timed out trying to connect...") 34 | 35 | 36 | # Connect and disconnect 100x in quick succession. Connect by scanning 37 | # and matching advertisement packets: 38 | def adv_report_filter(adv_report): 39 | return True # Just connect to anything that advertises... 40 | # return adv_report.addr == "c9:ea:a5:b8:c8:10" 41 | 42 | for _ in xrange(0, 100): 43 | connection = u.connect(adv_report_filter, timeout=10.0) 44 | LOG.debug("Connection %s" % connection) 45 | u.disconnect(connection) 46 | 47 | # Tear down the stack: 48 | b.quit() 49 | -------------------------------------------------------------------------------- /pybluetooth/address.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from scapy.layers.bluetooth import * 3 | from scapy.utils import mac2str, str2mac 4 | 5 | 6 | class AddressType(Enum): 7 | public = 0 8 | random = 1 9 | 10 | 11 | class Address(object): 12 | @staticmethod 13 | def from_packet(packet): 14 | if packet.getlayer(HCI_LE_Meta_Connection_Complete) is not None: 15 | return Address(packet.paddr, packet.patype) 16 | if packet.getlayer(HCI_LE_Meta_Advertising_Report) is not None: 17 | return Address(packet.addr, packet.atype) 18 | raise Exception("No mapping to Address for packet %s" % packet) 19 | 20 | def __init__(self, bd_addr, address_type=AddressType.random): 21 | if isinstance(address_type, int): 22 | address_type = AddressType(address_type) 23 | assert isinstance(bd_addr, str) 24 | assert len(bd_addr) >= 6 25 | if len(bd_addr) > 6: 26 | bd_addr = mac2str(bd_addr) 27 | assert len(bd_addr) == 6 28 | 29 | self.bd_addr = bd_addr 30 | self.address_type = address_type 31 | 32 | def __eq__(self, other): 33 | return ( 34 | isinstance(other, Address) and 35 | self.bd_addr == other.bd_addr and 36 | self.address_type == other.address_type) 37 | 38 | def is_random(self): 39 | return self.address_type == AddressType.random 40 | 41 | def is_public(self): 42 | return self.address_type == AddressType.public 43 | 44 | def macstr(self): 45 | return str2mac(self.bd_addr) 46 | 47 | def __str__(self): 48 | return "{} {}, address_type={}".format( 49 | super(Address, self).__str__(), str2mac(self.bd_addr), 50 | self.address_type) 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.test import test as TestCommand 3 | 4 | import sys 5 | 6 | 7 | class PyTest(TestCommand): 8 | def initialize_options(self): 9 | TestCommand.initialize_options(self) 10 | self.pytest_args = [] 11 | 12 | def finalize_options(self): 13 | TestCommand.finalize_options(self) 14 | self.test_args = [] 15 | self.test_suite = True 16 | 17 | def run_tests(self): 18 | # import here because outside the eggs aren't loaded 19 | import pytest 20 | errno = pytest.main(["-v"]) 21 | sys.exit(errno) 22 | 23 | __version__ = None # Overwritten by executing version.py. 24 | with open('pybluetooth/version.py') as f: 25 | exec(f.read()) 26 | 27 | requires = [ 28 | 'scapy==2.3.2.dev0', 29 | # scapy dependencies that aren't installed by scapy's setup.py itself: 30 | 'dnet==1.12', 31 | 'pcapy==0.10.10', 32 | 'pyusb==1.0.0b2', 33 | 'six==1.10.0', 34 | 'pbr==1.9.1', 35 | 'enum34==1.1.3' 36 | ] 37 | 38 | setup(name='pybluetooth', 39 | version=__version__, 40 | description='Python Bluetooth Library', 41 | long_description=open('README.md').read(), 42 | url='https://github.com/pebble/pybluetooth', 43 | author='Pebble Technology Corporation', 44 | author_email='martijn@pebble.com', 45 | license='MIT', 46 | packages=find_packages(), 47 | install_requires=requires, 48 | tests_require=[ 49 | 'pytest', 50 | 'pytest-mock', 51 | 'mock', 52 | 'scapy', 53 | 'dnet', 54 | 'pcapy', 55 | ], 56 | cmdclass={'test': PyTest}, 57 | classifiers=[ 58 | 'Development Status :: 2 - Pre-Alpha', 59 | 'Intended Audience :: Developers', 60 | 'License :: OSI Approved :: MIT License', 61 | 'Operating System :: MacOS :: MacOS X', 62 | 'Operating System :: POSIX', 63 | 'Programming Language :: Python :: 2.7', 64 | 'Programming Language :: Python :: 3', 65 | 'Programming Language :: Python :: 3.4', 66 | 'Topic :: System :: Hardware :: Hardware Drivers', 67 | 'Topic :: Software Development :: Libraries :: Python Modules', 68 | 'Topic :: Software Development :: Embedded Systems', 69 | 'Topic :: System :: Networking', 70 | ], 71 | zip_safe=True) 72 | -------------------------------------------------------------------------------- /tests/test_rxthread.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | try: 4 | from unittest.mock import MagicMock 5 | except ImportError: 6 | from mock import MagicMock 7 | 8 | from Queue import Queue, Empty 9 | from scapy.layers.bluetooth import * 10 | from scapy.packet import Packet 11 | from threading import Semaphore 12 | 13 | 14 | from pybluetooth import RxThread 15 | 16 | 17 | class TestRxThread(unittest.TestCase): 18 | def setUp(self): 19 | self.socket = MagicMock() 20 | self.rx_thread = RxThread(self.socket) 21 | 22 | self.mock_recv_queue = Queue() 23 | 24 | def mock_recv(x=512, timeout_secs=10.0): 25 | packet = self.mock_recv_queue.get() 26 | return packet 27 | self.socket.recv = mock_recv 28 | 29 | self.rx_thread.start() 30 | 31 | def simulate_recv_packet(self, packet): 32 | self.mock_recv_queue.put(packet) 33 | 34 | def test_add_remove_queue(self): 35 | # Add the queue and receive a packet: 36 | queue = Queue() 37 | self.rx_thread.add_packet_queue(lambda packet: True, queue) 38 | self.simulate_recv_packet(Packet()) 39 | packet = queue.get(block=True, timeout=0.1) 40 | self.assertEquals(packet, Packet()) 41 | 42 | # Remove the queue, receive a packet, it shouldn't get added: 43 | self.rx_thread.remove_packet_queue(queue) 44 | self.simulate_recv_packet(Packet()) 45 | with self.assertRaises(Empty): 46 | packet = queue.get(block=False, timeout=0.1) 47 | 48 | def test_queues_filtered_by_packet(self): 49 | queue = Queue() 50 | 51 | def adv_packet_filter(packet): 52 | return packet.getlayer(HCI_LE_Meta_Advertising_Report) != None 53 | self.rx_thread.add_packet_queue(adv_packet_filter, queue) 54 | 55 | # Receive cmd status, expect NOT to be added to queue: 56 | cmd_status_evt_packet = ( 57 | HCI_Hdr() / HCI_Event_Hdr() / HCI_Event_Command_Status()) 58 | self.simulate_recv_packet(cmd_status_evt_packet) 59 | with self.assertRaises(Empty): 60 | packet = queue.get(block=False, timeout=0.1) 61 | 62 | # Receive ad report packet, expect to be added to queue: 63 | adv_evt_packet = ( 64 | HCI_Hdr() / HCI_Event_Hdr() / HCI_Event_LE_Meta() / 65 | HCI_LE_Meta_Advertising_Report()) 66 | self.simulate_recv_packet(adv_evt_packet) 67 | recv_packet = queue.get(block=True, timeout=0.1) 68 | self.assertEquals(recv_packet, adv_evt_packet) 69 | 70 | 71 | if __name__ == '__main__': 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /tests/test_connection_manager.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import pytest 3 | 4 | from pybluetooth.address import * 5 | from pybluetooth.connection import ConnectionManager, Role, State 6 | from pybluetooth.exceptions import * 7 | from pybluetooth import CallbackThread 8 | from scapy.layers.bluetooth import * 9 | 10 | try: 11 | from unittest.mock import MagicMock 12 | except ImportError: 13 | from mock import MagicMock 14 | 15 | 16 | def _connection_complete_event_packet(): 17 | le_conn_complete_raw_data = binascii.unhexlify( 18 | "043e1301004800000110c8b8a5eac9380000002a0000") 19 | le_conn_complete_packet = HCI_Hdr(le_conn_complete_raw_data) 20 | return le_conn_complete_packet 21 | 22 | 23 | def _disconnection_complete_event_packet(): 24 | disconn_complete_raw_data = binascii.unhexlify("04050400480016") 25 | return HCI_Hdr(disconn_complete_raw_data) 26 | 27 | 28 | def test_connection_manager_connect_as_master_successful(): 29 | hci_thread = MagicMock() 30 | cb_thread = CallbackThread() 31 | mgr = ConnectionManager(hci_thread, cb_thread) 32 | 33 | address = Address("c9:ea:a5:b8:c8:10", AddressType.random) 34 | 35 | assert mgr.find_connection_by_address(address) is None 36 | connection = mgr.connect(address) 37 | assert connection is not None 38 | hci_thread.cmd_le_create_connection.assert_called_once_with(address) 39 | 40 | connection1 = mgr.connect(address) 41 | assert connection1 == connection 42 | 43 | connection2 = mgr.find_connection_by_address(address) 44 | assert connection2 == connection 45 | 46 | assert connection.state == State.initiating 47 | assert not connection.connected_event.is_set() 48 | assert connection.disconnected_event.is_set() 49 | with pytest.raises(TimeoutException): 50 | connection.wait_until_connected(0) 51 | connection.wait_until_disconnected(0) 52 | 53 | cb_thread.dispatch_packet(_connection_complete_event_packet()) 54 | 55 | assert connection.state == State.connected 56 | assert connection.connected_event.is_set() 57 | connection.wait_until_connected(0) 58 | with pytest.raises(TimeoutException): 59 | connection.wait_until_disconnected(0) 60 | assert connection.handle == 72 61 | assert connection.role == Role.master 62 | assert connection.interval_ms == 70 63 | assert connection.slave_latency == 0 64 | assert connection.supervision_timeout == 420 65 | assert len(mgr.connections) == 1 66 | 67 | mgr.disconnect(connection) 68 | assert connection.state == State.disconnecting 69 | assert not connection.connected_event.is_set() 70 | assert not connection.disconnected_event.is_set() 71 | assert len(mgr.connections) == 1 72 | 73 | cb_thread.dispatch_packet(_disconnection_complete_event_packet()) 74 | 75 | assert connection.state == State.disconnected 76 | assert connection.disconnected_event.is_set() 77 | connection.wait_until_disconnected(0) 78 | assert len(mgr.connections) == 0 79 | 80 | 81 | def test_connection_manager_disconnection_event(): 82 | pass 83 | -------------------------------------------------------------------------------- /pybluetooth/synchronous.py: -------------------------------------------------------------------------------- 1 | """ Class that exposes a synchronous API for various Bluetooth activities. 2 | """ 3 | 4 | import logging 5 | import Queue 6 | import time 7 | 8 | from scapy.layers.bluetooth import * 9 | 10 | from pybluetooth.address import * 11 | from pybluetooth.exceptions import * 12 | 13 | 14 | LOG = logging.getLogger("pybluetooth") 15 | 16 | 17 | class BTStackSynchronousUtils(object): 18 | def __init__(self, btstack): 19 | self.b = btstack 20 | 21 | def scan(self, duration_secs=5.0): 22 | LOG.debug("BTStackSynchronousUtils scan()") 23 | adv_report_queue = Queue.Queue() 24 | 25 | def adv_packet_filter(packet): 26 | return packet.getlayer(HCI_LE_Meta_Advertising_Report) is not None 27 | self.b.hci.add_packet_queue(adv_packet_filter, adv_report_queue) 28 | self.b.start_scan() 29 | time.sleep(duration_secs) 30 | self.b.stop_scan() 31 | self.b.hci.remove_packet_queue(adv_report_queue) 32 | 33 | reports = [] 34 | while True: 35 | try: 36 | r = adv_report_queue.get_nowait() 37 | reports += [r[HCI_LE_Meta_Advertising_Report]] 38 | except: 39 | break 40 | return reports 41 | 42 | def scan_until_match(self, packet_filter, timeout=None): 43 | LOG.debug("BTStackSynchronousUtils scan_until_match()") 44 | adv_report_queue = Queue.Queue() 45 | 46 | def adv_packet_filter(packet): 47 | if packet.getlayer(HCI_LE_Meta_Advertising_Report) is None: 48 | return False 49 | return packet_filter(packet[HCI_LE_Meta_Advertising_Report]) 50 | 51 | self.b.hci.add_packet_queue(adv_packet_filter, adv_report_queue) 52 | self.b.start_scan() 53 | try: 54 | report = adv_report_queue.get(block=True, timeout=timeout) 55 | except Queue.Empty: 56 | raise TimeoutException() 57 | finally: 58 | self.b.stop_scan() 59 | self.b.hci.remove_packet_queue(adv_report_queue) 60 | return report 61 | 62 | def connect(self, adv_filter_or_address, timeout=None, 63 | should_cancel_connecting_on_timeout=True): 64 | LOG.debug("BTStackSynchronousUtils connect()") 65 | if isinstance(adv_filter_or_address, Address): 66 | address = adv_filter_or_address 67 | else: 68 | adv_report = self.scan_until_match( 69 | adv_filter_or_address, timeout=timeout) 70 | address = Address.from_packet(adv_report) 71 | connection = self.b.connect(address) 72 | try: 73 | connection.wait_until_connected(timeout=timeout) 74 | except TimeoutException as e: 75 | if should_cancel_connecting_on_timeout: 76 | self.disconnect(connection) 77 | raise e 78 | return connection 79 | 80 | def disconnect(self, connection, timeout=None): 81 | self.b.disconnect(connection) 82 | connection.wait_until_disconnected(timeout=timeout) 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Bluetooth Stack 2 | 3 | - The goal of this library is to be a pure-Python implementation of a Bluetooth LE host stack, 4 | geared towards, but not limited to, automated testing of Bluetooth devices such as [Pebble][0] 5 | smartwatches. 6 | - Focussed on Bluetooth Low Energy (4.x) for now. 7 | - Uses [PyUSB][1] to communicate with standard Bluetooth USB dongles. 8 | - Tested on Mac OS X: 9 | - [CSR8510 BT 4.0 USB dongle][2], should work with other standard BT 4.0 USB dongles. 10 | - Built-in Bluetooth of my Mid-2015 MacBook Pro 15". 11 | - Relies on [scapy][3] to do most of the packet parsing / building. 12 | - Inspired by [mikeryan's PyBT][5] project. 13 | - *WARNING: Many things aren't implemented! Please contribute!* 14 | 15 | ### Installation (OS X) 16 | 17 | Step 1: Install [scapy][3] system dependencies: 18 | 19 | ``` 20 | $ brew tap homebrew/dupes 21 | $ brew install libpcap libdnet 22 | ``` 23 | 24 | Step 2: Because this project currently depends on a features in [scapy][3] that have not yet been 25 | released, you'll need install scapy from Pebble's clone. If you're a Pebble employee, you should be 26 | able to install [scapy][3] from the internal pypi server, and therefore skip this step. 27 | 28 | ``` 29 | $ git clone https://github.com/pebble/scapy.git 30 | $ cd scapy && pip install -e . 31 | ``` 32 | 33 | Step 3: Then go on to install `pybluetooth`: 34 | 35 | ``` 36 | $ git clone https://github.com/pebble/pybluetooth.git 37 | $ cd pybluetooth && pip install -e . 38 | ``` 39 | 40 | Step 4: Try out one of the examples: 41 | 42 | ``` 43 | $ ./examples/scan.py 44 | ``` 45 | 46 | ### Using a Bluetooth USB dongle on Mac OS X 47 | 48 | OS X ships with drivers for various Bluetooth devices, so chances are that the built-in drivers 49 | (kernel extensions or .kexts) will latch onto the dongle as soon as you plug it in. To prevent this 50 | you can unload or rename the .kext (see `/System/Library/Extensions/`) but this will also break your 51 | Mac's built-in Bluetooth. If you want to use the built-in Bluetooth of your Mac, there is no other 52 | option than to disable the Bluetooth .kexts. The steps to do this are: 53 | 54 | 1. Disable [OS X' System Integrity Protection][4] by following one of the numerous how-tos on the interwebs. 55 | 2. `$ sudo mv /System/Library/Extensions/IOBluetoothFamily.kext /System/Library/Extensions/IOBluetoothFamily.kext.dontload` 56 | 3. Reboot. 57 | 4. Done ;) 58 | 59 | Alternatively, some chipsets support changing the USB VendorId and ProductId of the dongle. If the 60 | VendorId and/or ProductId of the dongle doesn't match with what the driver can support, it will 61 | leave the dongle along and free for this library to use it. 62 | 63 | Dongles that allow the USB info to be changed: 64 | 65 | - CSR based dongles: use PSTool, which is part of CSR's' BlueSuite developer tools. 66 | - ... 67 | 68 | [0]: http://www.pebble.com/ 69 | [1]: http://walac.github.io/pyusb/ 70 | [2]: http://www.amazon.com/s/ref=nb_sb_noss?url=search-alias%3Daps&field-keywords=CSR8510 71 | [3]: https://github.com/pebble/scapy 72 | [4]: https://support.apple.com/en-us/HT204899 73 | [5]: https://github.com/mikeryan/PyBT -------------------------------------------------------------------------------- /pybluetooth/hci_event_mask.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | 4 | """ From Core v4.2, Vol 2, Part E, 7.3.1 Set Event Mask Command """ 5 | HCIEventMaskValues = { 6 | 0x0000000000000001: "Inquiry Complete Event", 7 | 0x0000000000000002: "Inquiry Result Event", 8 | 0x0000000000000004: "Connection Complete Event", 9 | 0x0000000000000008: "Connection Request Event", 10 | 0x0000000000000010: "Disconnection Complete Event", 11 | 0x0000000000000020: "Authentication Complete Event", 12 | 0x0000000000000040: "Remote Name Request Complete Event", 13 | 0x0000000000000080: "Encryption Change Event", 14 | 0x0000000000000100: "Change Connection Link Key Complete Event", 15 | 0x0000000000000200: "Master Link Key Complete Event", 16 | 0x0000000000000400: "Read Remote Supported Features Complete Event", 17 | 0x0000000000000800: "Read Remote Version Information Complete Event", 18 | 0x0000000000001000: "QoS Setup Complete Event", 19 | 0x0000000000002000: "Reserved", 20 | 0x0000000000004000: "Reserved", 21 | 0x0000000000008000: "Hardware Error Event", 22 | 0x0000000000010000: "Flush Occurred Event", 23 | 0x0000000000020000: "Role Change Event", 24 | 0x0000000000040000: "Reserved", 25 | 0x0000000000080000: "Mode Change Event", 26 | 0x0000000000100000: "Return Link Keys Event", 27 | 0x0000000000200000: "PIN Code Request Event", 28 | 0x0000000000400000: "Link Key Request Event", 29 | 0x0000000000800000: "Link Key Notification Event", 30 | 0x0000000001000000: "Loopback Command Event", 31 | 0x0000000002000000: "Data Buffer Overflow Event", 32 | 0x0000000004000000: "Max Slots Change Event", 33 | 0x0000000008000000: "Read Clock Offset Complete Event", 34 | 0x0000000010000000: "Connection Packet Type Changed Event", 35 | 0x0000000020000000: "QoS Violation Event", 36 | 0x0000000040000000: "Page Scan Mode Change Event [deprecated]", 37 | 0x0000000080000000: "Page Scan Repetition Mode Change Event", 38 | 0x0000000100000000: "Flow Specification Complete Event", 39 | 0x0000000200000000: "Inquiry Result with RSSI Event", 40 | 0x0000000400000000: "Read Remote Extended Features Complete Event", 41 | 0x0000000800000000: "Reserved", 42 | 0x0000001000000000: "Reserved", 43 | 0x0000002000000000: "Reserved", 44 | 0x0000004000000000: "Reserved", 45 | 0x0000008000000000: "Reserved", 46 | 0x0000010000000000: "Reserved", 47 | 0x0000020000000000: "Reserved", 48 | 0x0000040000000000: "Reserved", 49 | 0x0000080000000000: "Synchronous Connection Complete Event", 50 | 0x0000100000000000: "Synchronous Connection Changed Event", 51 | 0x0000200000000000: "Sniff Subrating Event", 52 | 0x0000400000000000: "Extended Inquiry Result Event", 53 | 0x0000800000000000: "Encryption Key Refresh Complete Event", 54 | 0x0001000000000000: "IO Capability Request Event", 55 | 0x0002000000000000: "IO Capability Request Reply Event", 56 | 0x0004000000000000: "User Confirmation Request Event", 57 | 0x0008000000000000: "User Passkey Request Event", 58 | 0x0010000000000000: "Remote OOB Data Request Event", 59 | 0x0020000000000000: "Simple Pairing Complete Event", 60 | 0x0040000000000000: "Reserved", 61 | 0x0080000000000000: "Link Supervision Timeout Changed Event", 62 | 0x0100000000000000: "Enhanced Flush Complete Event", 63 | 0x0200000000000000: "Reserved", 64 | 0x0400000000000000: "User Passkey Notification Event", 65 | 0x0800000000000000: "Keypress Notification Event", 66 | 0x1000000000000000: "Remote Host Supported Features Notification Event", 67 | 0x2000000000000000: "LE Meta-Event", 68 | 0xC000000000000000: "Reserved", 69 | } 70 | 71 | 72 | def to_little_endian_bytes(v): 73 | return struct.pack(" 1: 181 | LOG.warn("More than 1 Bluetooth adapters found, " 182 | "using the first one...") 183 | pyusb_dev = pyusb_devs[0] 184 | 185 | return pyusb_dev 186 | 187 | 188 | def find_first_bt_adapter_pyusb_device(): 189 | try: 190 | return find_first_bt_adapter_pyusb_device_or_raise() 191 | except PyUSBBluetoothNoAdapterFoundException: 192 | return None 193 | 194 | 195 | def has_bt_adapter(): 196 | pyusb_dev = find_first_bt_adapter_pyusb_device() 197 | if pyusb_dev is None: 198 | return False 199 | return True 200 | 201 | 202 | def pebble_usb_class_matcher(d): 203 | """ USB device class matcher for Pebble's Test Automation dongles """ 204 | USB_DEVICE_CLASS_VENDOR_SPECIFIC = 0xFF 205 | USB_DEVICE_SUB_CLASS_PEBBLE_BT = 0xBB 206 | USB_DEVICE_PROTOCOL_PEBBLE_BT = 0xBB 207 | return (d.bDeviceClass == USB_DEVICE_CLASS_VENDOR_SPECIFIC and 208 | d.bDeviceSubClass == USB_DEVICE_SUB_CLASS_PEBBLE_BT and 209 | d.bDeviceProtocol == USB_DEVICE_PROTOCOL_PEBBLE_BT) 210 | 211 | CUSTOM_USB_DEVICE_MATCHER = pebble_usb_class_matcher 212 | 213 | 214 | def set_custom_matcher(matcher_func): 215 | CUSTOM_USB_DEVICE_MATCHER = matcher_func 216 | -------------------------------------------------------------------------------- /pybluetooth/__init__.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import logging 3 | import Queue 4 | from scapy.layers.bluetooth import * 5 | from threading import Event, RLock, Thread 6 | 7 | import hci_event_mask 8 | import pyusb_bt_sockets 9 | from connection import ConnectionManager 10 | 11 | LOG = logging.getLogger("pybluetooth") 12 | 13 | 14 | class HCIResponseTimeoutException(Exception): 15 | pass 16 | 17 | 18 | class KillableThread(Thread): 19 | def __init__(self): 20 | super(KillableThread, self).__init__() 21 | self.daemon = True 22 | self.is_killed = Event() 23 | 24 | def run_loop(self): 25 | raise Exception("Unimplemented") 26 | 27 | def run(self): 28 | while not self.is_killed.is_set(): 29 | self.run_loop() 30 | 31 | def kill(self): 32 | self.is_killed.set() 33 | 34 | 35 | class CallbackThread(KillableThread): 36 | """ Thread that executes callbacks on behalf various subsystems to process 37 | and act upon received packets. """ 38 | def __init__(self): 39 | super(CallbackThread, self).__init__() 40 | self.packet_queue = Queue.Queue() 41 | self.lock = RLock() 42 | self.callbacks = dict() 43 | 44 | def callbacks_filtered_by_packet(self, packet): 45 | matching_callbacks = [] 46 | with self.lock: 47 | for callback in self.callbacks: 48 | packet_filter = self.callbacks[callback] 49 | if packet_filter(packet): 50 | matching_callbacks.append(callback) 51 | return matching_callbacks 52 | 53 | def has_callback_for_packet(self, packet): 54 | return len(self.callbacks_filtered_by_packet(packet)) > 0 55 | 56 | def register_with_rx_thread(self, rx_thread): 57 | rx_thread.add_packet_queue( 58 | self.has_callback_for_packet, self.packet_queue) 59 | 60 | def add_callback(self, packet_filter, callback): 61 | with self.lock: 62 | self.callbacks[callback] = packet_filter 63 | 64 | def remove_packet_queue(self, callback): 65 | with self.lock: 66 | del self.callbacks[callback] 67 | 68 | def dispatch_packet(self, packet): 69 | matching_callbacks = self.callbacks_filtered_by_packet(packet) 70 | for callback in matching_callbacks: 71 | callback(packet) 72 | 73 | def run_loop(self): 74 | try: 75 | packet = self.packet_queue.get(block=True, timeout=0.1) 76 | except Queue.Empty: 77 | return # Nothing to receive, loop again 78 | self.dispatch_packet(packet) 79 | 80 | 81 | class RxThread(KillableThread): 82 | def __init__(self, socket): 83 | super(RxThread, self).__init__() 84 | self.socket = socket 85 | self.lock = RLock() 86 | self.packet_queues = dict() 87 | 88 | def run_loop(self): 89 | packet = self.socket.recv(timeout_secs=0.1) 90 | if packet is None: 91 | return # Nothing to receive, loop again 92 | matching_queues = self.queues_filtered_by_packet(packet) 93 | if not matching_queues: 94 | LOG.warn( 95 | "Dropping packet, no handler queue!\n%s" % packet.show()) 96 | for queue in matching_queues: 97 | queue.put(packet) 98 | 99 | def queues_filtered_by_packet(self, packet): 100 | matching_queues = [] 101 | with self.lock: 102 | for queue in self.packet_queues: 103 | packet_filter = self.packet_queues[queue] 104 | if packet_filter(packet): 105 | matching_queues.append(queue) 106 | return matching_queues 107 | 108 | def add_packet_queue(self, packet_filter, queue): 109 | """ packet_filter takes a Packet and returns True if it should be added 110 | to the queue, or False if it should not be added to the queue. 111 | """ 112 | with self.lock: 113 | self.packet_queues[queue] = packet_filter 114 | 115 | def remove_packet_queue(self, queue): 116 | with self.lock: 117 | del self.packet_queues[queue] 118 | 119 | 120 | def _create_response_filter(packet_type): 121 | def _create_hci_response_packet_filter(request_packet): 122 | opcode = request_packet.overload_fields[HCI_Command_Hdr]['opcode'] 123 | 124 | def _hci_cmd_complete_packet_filter(packet): 125 | if not packet.getlayer(packet_type): 126 | return False 127 | return packet.opcode == opcode 128 | 129 | return _hci_cmd_complete_packet_filter 130 | 131 | return _create_hci_response_packet_filter 132 | 133 | 134 | def _create_hci_cmd_complete_packet_filter(): 135 | return _create_response_filter(HCI_Event_Command_Complete) 136 | 137 | 138 | def _create_hci_cmd_status_packet_filter(): 139 | return _create_response_filter(HCI_Event_Command_Status) 140 | 141 | 142 | class HCIThread(RxThread): 143 | RESPONSE_TIMEOUT_SECS = 5.0 144 | 145 | def send_cmd(self, scapy_hci_cmd, 146 | response_filter_creator=_create_hci_cmd_complete_packet_filter(), 147 | response_timeout_secs=RESPONSE_TIMEOUT_SECS): 148 | response_queue = None 149 | if response_filter_creator: 150 | response_filter = response_filter_creator(scapy_hci_cmd) 151 | response_queue = Queue.Queue() 152 | self.add_packet_queue(response_filter, response_queue) 153 | 154 | full_hci_cmd = HCI_Hdr() / HCI_Command_Hdr() / scapy_hci_cmd 155 | self.socket.send(full_hci_cmd) 156 | 157 | if response_queue: 158 | try: 159 | cmd_status = response_queue.get( 160 | block=True, timeout=response_timeout_secs) 161 | except Queue.Empty: 162 | raise HCIResponseTimeoutException( 163 | "HCI command timed out: %s" % 164 | full_hci_cmd.lastlayer().summary()) 165 | return cmd_status 166 | 167 | def cmd_reset(self): 168 | self.send_cmd(HCI_Cmd_Reset()) 169 | 170 | def cmd_set_event_filter_clear_all_filters(self): 171 | self.send_cmd(HCI_Cmd_Set_Event_Filter()) 172 | 173 | def cmd_set_event_mask(self, mask=hci_event_mask.all_enabled_str()): 174 | self.send_cmd(HCI_Cmd_Set_Event_Mask(mask=mask)) 175 | 176 | def cmd_le_host_supported(self, le_supported=True): 177 | self.send_cmd(HCI_Cmd_LE_Host_Supported( 178 | supported=1 if le_supported else 0, 179 | simultaneous=0)) # As per 4.2 spec: "This value shall be ignored." 180 | 181 | def cmd_le_read_buffer_size(self): 182 | self.send_cmd(HCI_Cmd_LE_Read_Buffer_Size()) 183 | 184 | def cmd_read_bd_addr(self): 185 | def _create_read_bd_addr_response_filter(request_packet): 186 | def _read_bd_addr_response_filter(packet): 187 | return packet.getlayer(HCI_Cmd_Complete_Read_BD_Addr) \ 188 | is not None 189 | return _read_bd_addr_response_filter 190 | resp = self.send_cmd( 191 | HCI_Cmd_Read_BD_Addr(), 192 | response_filter_creator=_create_read_bd_addr_response_filter) 193 | return str(resp[HCI_Cmd_Complete_Read_BD_Addr]) 194 | 195 | def cmd_le_scan_enable(self, enable, filter_dups=True): 196 | self.send_cmd(HCI_Cmd_LE_Set_Scan_Enable( 197 | enable=enable, filter_dups=filter_dups)) 198 | 199 | def cmd_le_scan_params(self, active_scanning=True, interval_ms=10, 200 | window_ms=10, **kwargs): 201 | scan_type = 1 if active_scanning else 0 202 | self.send_cmd(HCI_Cmd_LE_Set_Scan_Parameters( 203 | type=scan_type, interval=interval_ms * 0.625, 204 | window=window_ms * 0.625, **kwargs)) 205 | 206 | def cmd_le_create_connection(self, address, 207 | is_identity_address=False, 208 | interval_ms=10, 209 | window_ms=10, **kwargs): 210 | address_type_map = { 211 | (True, False): 0x00, # Public 212 | (False, False): 0x01, # Random 213 | (True, True): 0x02, # Public Identity 214 | (False, True): 0x03, # Random Identity 215 | } 216 | self.send_cmd(HCI_Cmd_LE_Create_Connection( 217 | paddr=address.macstr(), 218 | patype=address_type_map[(address.is_public(), is_identity_address)], 219 | interval=interval_ms * 0.625, 220 | window=window_ms * 0.625, 221 | **kwargs), 222 | response_filter_creator=_create_hci_cmd_status_packet_filter()) 223 | 224 | def cmd_le_connection_create_cancel(self): 225 | self.send_cmd(HCI_Cmd_LE_Create_Connection_Cancel()) 226 | 227 | def cmd_disconnect(self, handle): 228 | self.send_cmd( 229 | HCI_Cmd_Disconnect(handle=handle), 230 | response_filter_creator=_create_hci_cmd_status_packet_filter()) 231 | 232 | 233 | class BTStack(object): 234 | def __init__(self, pyusb_dev=None): 235 | if not pyusb_dev: 236 | pyusb_dev = \ 237 | pyusb_bt_sockets.find_first_bt_adapter_pyusb_device_or_raise() 238 | self.hci_socket = pyusb_bt_sockets.PyUSBBluetoothHCISocket(pyusb_dev) 239 | self.hci = HCIThread(self.hci_socket) 240 | self.cb_thread = CallbackThread() 241 | self.cb_thread.register_with_rx_thread(self.hci) 242 | self.is_scannning_enabled = False 243 | self.connection_mgr = ConnectionManager( 244 | self.hci, self.cb_thread) 245 | self.address = None 246 | 247 | def start(self): 248 | LOG.debug("BTStack start()") 249 | 250 | # During reset, just ignore and eat all packets that might come in: 251 | ignore_queue = Queue.Queue() 252 | self.hci.add_packet_queue(lambda packet: True, ignore_queue) 253 | self.hci.start() 254 | self.hci.cmd_reset() 255 | self.hci.remove_packet_queue(ignore_queue) 256 | 257 | self.cb_thread.start() 258 | 259 | self.hci.cmd_set_event_filter_clear_all_filters() 260 | self.hci.cmd_set_event_mask() 261 | self.hci.cmd_le_host_supported() 262 | 263 | # "The LE_Read_Buffer_Size command must be issued by the Host before it 264 | # sends any data to an LE Controller": 265 | self.hci.cmd_le_read_buffer_size() 266 | 267 | self.address = self.hci.cmd_read_bd_addr() 268 | 269 | def start_scan(self): 270 | assert self.is_scannning_enabled is False 271 | self.hci.cmd_le_scan_params() 272 | self.hci.cmd_le_scan_enable(True) 273 | self.is_scannning_enabled = True 274 | 275 | def stop_scan(self): 276 | assert self.is_scannning_enabled is True 277 | self.hci.cmd_le_scan_enable(False) 278 | self.is_scannning_enabled = False 279 | 280 | def connect(self, address): 281 | return self.connection_mgr.connect(address) 282 | 283 | def disconnect(self, connection): 284 | self.connection_mgr.disconnect(connection) 285 | 286 | def quit(self): 287 | LOG.debug("BTStack quit()") 288 | self.hci.cmd_reset() 289 | self.hci.kill() 290 | self.cb_thread.kill() 291 | 292 | 293 | def has_bt_adapter(): 294 | return pyusb_bt_sockets.has_bt_adapter() 295 | --------------------------------------------------------------------------------