├── test ├── no_imports │ ├── __init__.py │ └── test_noimports.py ├── unit_tests │ ├── __init__.py │ ├── test_gatttoolhelper.py │ ├── test_versioncheck.py │ ├── test_bluetooth_interface.py │ ├── test_available_backends.py │ ├── test_bluepy.py │ └── test_gatttool.py ├── integration_tests │ ├── __init__.py │ ├── test_bluepy.py │ └── test_gatttool.py ├── __init__.py ├── conftest.py └── helper.py ├── MANIFEST.in ├── requirements.txt ├── setup.cfg ├── btlewrap ├── version.py ├── __init__.py ├── pygatt.py ├── bluepy.py ├── base.py └── gatttool.py ├── requirements-test.txt ├── pylintrc ├── release.sh ├── .coveragerc ├── .travis.yml ├── run_integration_tests ├── tox.ini ├── LICENSE ├── setup.py ├── .gitignore └── README.rst /test/no_imports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /test/integration_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bluepy==1.3.0 2 | pygatt==3.2.0 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests fpr btlewrap.""" 2 | TEST_MAC = '11:22:33:44:55:66' 3 | -------------------------------------------------------------------------------- /btlewrap/version.py: -------------------------------------------------------------------------------- 1 | """Version number of the library.""" 2 | 3 | __version__ = '0.0.11-dev' 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-timeout 4 | pylint==2.4.2 5 | flake8 6 | pexpect -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = abstract-method, too-many-instance-attributes, too-many-arguments, bad-whitespace, import-outside-toplevel 3 | 4 | [FORMAT] 5 | max-line-length=120 -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # release current branch to pypi 3 | 4 | echo Releasing version: 5 | grep __version__ btlewrap/version.py 6 | read -n1 -r -p "Press any key to continue..." key 7 | 8 | rm -r dist 9 | python3 setup.py sdist 10 | twine upload dist/* 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = btlewrap 3 | 4 | omit = 5 | btlewrap/bluepy.py 6 | btlewrap/pygatt.py 7 | 8 | [report] 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | matrix: 4 | fast_finish: true 5 | include: 6 | - python: '3.5' 7 | env: TOXENV=py35 8 | - python: '3.6' 9 | env: TOXENV=py36 10 | - python: '3.7' 11 | env: TOXENV=py37 12 | - python: '3.8' 13 | env: TOXENV=py38 14 | - python: '3.6' 15 | env: TOXENV=flake8 16 | - python: '3.6' 17 | env: TOXENV=pylint 18 | 19 | install: pip install -U tox coveralls 20 | script: tox 21 | after_success: coveralls 22 | -------------------------------------------------------------------------------- /test/no_imports/test_noimports.py: -------------------------------------------------------------------------------- 1 | """These tests check what happens if not of the bluetooth libraries are installed.""" 2 | import unittest 3 | from btlewrap import BluepyBackend, PygattBackend 4 | 5 | 6 | class TestNoImports(unittest.TestCase): 7 | """These tests check what happens if not of the bluetooth libraries are installed.""" 8 | 9 | def test_bluepy_check(self): 10 | """Test bluepy check_backend.""" 11 | self.assertFalse(BluepyBackend.check_backend()) 12 | 13 | def test_pygatt_check(self): 14 | """Test pygatt check_backend.""" 15 | self.assertFalse(PygattBackend.check_backend()) 16 | -------------------------------------------------------------------------------- /run_integration_tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | # simple script to run the integration tests 4 | # 5 | # - tests need to run as root so we can scan for new devices 6 | # - store the mac address of the device you're using in a file 7 | # called ".test_mac" so you can run the test with one call. 8 | 9 | MAC=`cat .test_mac` 10 | 11 | if [ $# -eq 0 ]; then 12 | SUITE=test/integration_tests 13 | else 14 | SUITE=$1 15 | fi 16 | TOX=$(which tox) 17 | sudo ${TOX} -e integration_tests -- --mac=$MAC $SUITE 18 | # clean up the file system permissions after running the tests as root 19 | sudo chown -R $UID .cache .tox .pytest_cache *.egg-info 20 | 21 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """Configure pytest for integration tests.""" 2 | import logging 3 | import pytest 4 | 5 | 6 | def pytest_addoption(parser): 7 | """Setup test environment for pytest. 8 | 9 | Changes: 10 | - Add command line parameter '--mac=' to pytest. 11 | - enable logging to console 12 | """ 13 | parser.addoption("--mac", action="store", help="mac address of sensor to be used for testing") 14 | logging.basicConfig(level=logging.DEBUG) 15 | 16 | 17 | @pytest.fixture(scope="class") 18 | def mac(request): 19 | """Get command line parameter and store it in class""" 20 | request.cls.mac = request.config.getoption("--mac") 21 | -------------------------------------------------------------------------------- /test/integration_tests/test_bluepy.py: -------------------------------------------------------------------------------- 1 | """Test btlewrap by connecting to a real device.""" 2 | 3 | import unittest 4 | import pytest 5 | from btlewrap import BluepyBackend 6 | 7 | 8 | class TestBluepy(unittest.TestCase): 9 | """Test btlewrap by connecting to a real device.""" 10 | # pylint does not understand pytest fixtures, so we have to disable the warning 11 | # pylint: disable=no-member 12 | 13 | def setUp(self): 14 | """Set up the test environment.""" 15 | self.backend = BluepyBackend() 16 | 17 | @pytest.mark.usefixtures("mac") 18 | def test_connect(self): 19 | """Try connecting to a device.""" 20 | self.backend.connect(self.mac) 21 | self.backend.disconnect() 22 | -------------------------------------------------------------------------------- /test/integration_tests/test_gatttool.py: -------------------------------------------------------------------------------- 1 | """Test btlewrap by connecting to a real device.""" 2 | 3 | import unittest 4 | import pytest 5 | from btlewrap import GatttoolBackend 6 | 7 | 8 | class TestGatttool(unittest.TestCase): 9 | """Test btlewrap by connecting to a real device.""" 10 | # pylint does not understand pytest fixtures, so we have to disable the warning 11 | # pylint: disable=no-member 12 | 13 | def setUp(self): 14 | """Set up the test environment.""" 15 | self.backend = GatttoolBackend() 16 | 17 | @pytest.mark.usefixtures("mac") 18 | def test_connect(self): 19 | """Try connecting to a device.""" 20 | self.backend.connect(self.mac) 21 | self.backend.disconnect() 22 | -------------------------------------------------------------------------------- /test/unit_tests/test_gatttoolhelper.py: -------------------------------------------------------------------------------- 1 | """Test helper functions.""" 2 | 3 | import unittest 4 | from btlewrap import GatttoolBackend 5 | 6 | 7 | class TestGatttoolHelper(unittest.TestCase): 8 | """Test helper functions.""" 9 | 10 | def test_byte_to_handle(self): 11 | """Test conversion of handles.""" 12 | self.assertEqual('0x0B', GatttoolBackend.byte_to_handle(0x0B)) 13 | self.assertEqual('0xAF', GatttoolBackend.byte_to_handle(0xAF)) 14 | self.assertEqual('0xAABB', GatttoolBackend.byte_to_handle(0xAABB)) 15 | 16 | def test_bytes_to_string(self): 17 | """Test conversion of byte arrays.""" 18 | self.assertEqual('0A0B', GatttoolBackend.bytes_to_string(bytes([0x0A, 0x0B]))) 19 | self.assertEqual('0x0C0D', GatttoolBackend.bytes_to_string(bytes([0x0C, 0x0D]), True)) 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38, pylint, flake8, noimport 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | # only run unit tests as they do not need additional hardware 7 | deps = -rrequirements.txt 8 | -rrequirements-test.txt 9 | passenv = TRAVIS TRAVIS_* 10 | commands = pytest --cov=btlewrap --timeout=10 test/unit_tests 11 | 12 | [testenv:noimport] 13 | # run tests without installing any Bluetooth libraries 14 | deps= -rrequirements-test.txt 15 | commands = pytest --timeout=10 test/no_imports 16 | 17 | [testenv:integration_tests] 18 | #there tests are run separately as they rquire read hardware 19 | #need the command line argument --mac= to work 20 | commands = pytest --timeout=60 {posargs} 21 | 22 | [flake8] 23 | max-complexity = 10 24 | install-hook=git 25 | max-line-length=120 26 | 27 | [testenv:flake8] 28 | base=python3 29 | ignore_errors=True 30 | commands=flake8 test btlewrap 31 | 32 | [testenv:pylint] 33 | basepython = python3 34 | skip_install = true 35 | commands = pylint -j4 btlewrap test 36 | -------------------------------------------------------------------------------- /test/unit_tests/test_versioncheck.py: -------------------------------------------------------------------------------- 1 | """Tests for the python version check. 2 | 3 | Tests that an execption is thrown when loading the class in Python version <3.4 4 | """ 5 | import unittest 6 | import sys 7 | 8 | 9 | class TestVersioncheck(unittest.TestCase): 10 | """Tests for the python version check.""" 11 | MIN_SUPPORTED_VERSION = (3, 4) 12 | 13 | def test_py2(self): 14 | """Make sure older python versions throw an exception.""" 15 | if sys.version_info >= self.MIN_SUPPORTED_VERSION: 16 | return 17 | try: 18 | import btlewrap # noqa: F401 # pylint: disable=unused-import 19 | self.fail('Should have thrown an exception') 20 | except ValueError as val_err: 21 | self.assertIn('version', str(val_err)) 22 | 23 | def test_py3(self): 24 | """Make sure newer python versions do not throw an exception.""" 25 | if sys.version_info < self.MIN_SUPPORTED_VERSION: 26 | return 27 | import btlewrap # noqa: F401 # pylint: disable=unused-import 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 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 | -------------------------------------------------------------------------------- /test/unit_tests/test_bluetooth_interface.py: -------------------------------------------------------------------------------- 1 | """Tests for the BluetoothInterface class.""" 2 | import unittest 3 | from test.helper import MockBackend 4 | from btlewrap.base import BluetoothInterface 5 | 6 | 7 | class TestBluetoothInterface(unittest.TestCase): 8 | """Tests for the BluetoothInterface class.""" 9 | 10 | def test_context_manager_locking(self): 11 | """Test the usage of the with statement.""" 12 | bluetooth_if = BluetoothInterface(MockBackend) 13 | self.assertFalse(bluetooth_if.is_connected()) 14 | 15 | with bluetooth_if.connect('abc'): # as connection: 16 | self.assertTrue(bluetooth_if.is_connected()) 17 | 18 | self.assertFalse(bluetooth_if.is_connected()) 19 | 20 | def test_exception_in_with(self): 21 | """Test clean exit after exception.""" 22 | bluetooth_if = BluetoothInterface(MockBackend) 23 | self.assertFalse(bluetooth_if.is_connected()) 24 | with self.assertRaises(ValueError): 25 | with bluetooth_if.connect('abc'): 26 | raise ValueError('some test exception') 27 | self.assertFalse(bluetooth_if.is_connected()) 28 | -------------------------------------------------------------------------------- /test/unit_tests/test_available_backends.py: -------------------------------------------------------------------------------- 1 | """Tests for miflora.available_backends.""" 2 | import unittest 3 | from unittest import mock 4 | from btlewrap import available_backends, BluepyBackend, GatttoolBackend, PygattBackend 5 | 6 | 7 | class TestAvailableBackends(unittest.TestCase): 8 | """Tests for miflora.available_backends.""" 9 | 10 | @mock.patch('btlewrap.gatttool.call', return_value=None) 11 | def test_all(self, _): 12 | """Tests with all backends available. 13 | 14 | bluepy is installed via tox, gatttool is mocked. 15 | """ 16 | backends = available_backends() 17 | self.assertEqual(3, len(backends)) 18 | self.assertIn(BluepyBackend, backends) 19 | self.assertIn(GatttoolBackend, backends) 20 | self.assertIn(PygattBackend, backends) 21 | 22 | @mock.patch('btlewrap.gatttool.call', **{'side_effect': IOError()}) 23 | def test_one_missing(self, _): 24 | """Tests with all backends available. 25 | 26 | bluepy is installed via tox, gatttool is mocked. 27 | """ 28 | backends = available_backends() 29 | self.assertEqual(2, len(backends)) 30 | self.assertIn(BluepyBackend, backends) 31 | self.assertIn(PygattBackend, backends) 32 | -------------------------------------------------------------------------------- /btlewrap/__init__.py: -------------------------------------------------------------------------------- 1 | """Public interface for btlewrap.""" 2 | import sys 3 | from btlewrap.version import __version__ # noqa: F401 4 | 5 | # This check must be run first, so that it fails before loading the other modules. 6 | # Otherwise we do not get a clean error message. 7 | if sys.version_info < (3, 4): 8 | raise ValueError('this library requires at least Python 3.4. ' + 9 | 'You\'re running version {}.{} from {}.'.format( 10 | sys.version_info.major, 11 | sys.version_info.minor, 12 | sys.executable)) 13 | 14 | 15 | from btlewrap.base import BluetoothBackendException # noqa: F401,E402 # pylint: disable=wrong-import-position 16 | 17 | from btlewrap.bluepy import BluepyBackend # noqa: E402 # pylint: disable=wrong-import-position 18 | from btlewrap.gatttool import GatttoolBackend # noqa: E402 # pylint: disable=wrong-import-position 19 | from btlewrap.pygatt import PygattBackend # noqa: E402 # pylint: disable=wrong-import-position 20 | 21 | 22 | _ALL_BACKENDS = [BluepyBackend, GatttoolBackend, PygattBackend] 23 | 24 | 25 | def available_backends(): 26 | """Returns a list of all available backends.""" 27 | return [b for b in _ALL_BACKENDS if b.check_backend()] 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Python package description.""" 3 | from setuptools import setup, find_packages 4 | from btlewrap.version import __version__ as version 5 | 6 | 7 | def readme(): 8 | """Load the readme file.""" 9 | with open('README.rst') as readme_file: 10 | return readme_file.read() 11 | 12 | 13 | setup( 14 | name='btlewrap', 15 | version=version, 16 | description='wrapper around different bluetooth low energy backends', 17 | url='https://github.com/ChristianKuehnel/btlewrap', 18 | author='Christian Kuehnel', 19 | author_email='christian.kuehnel@gmail.com', 20 | long_description=readme(), 21 | license='MIT', 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Intended Audience :: Developers', 25 | 'Topic :: System :: Hardware :: Hardware Drivers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.4', 29 | 'Programming Language :: Python :: 3.5', 30 | 'Programming Language :: Python :: 3.6', 31 | 'Programming Language :: Python :: 3.7', 32 | ], 33 | packages=find_packages(), 34 | keywords='bluetooth low-energy ble', 35 | zip_safe=False, 36 | extras_require={ 37 | 'testing': ['pytest'], 38 | 'bluepy': ['bluepy==1.3.0'], 39 | 'pygatt': ['pygatt==3.4.0'], 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /.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 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .idea 103 | .tox 104 | .pytest_cache/ 105 | *.swp 106 | .test_mac -------------------------------------------------------------------------------- /test/helper.py: -------------------------------------------------------------------------------- 1 | """Helpers for test cases.""" 2 | from btlewrap.base import AbstractBackend 3 | 4 | 5 | class MockBackend(AbstractBackend): 6 | """Mockup of a Backend and Sensor. 7 | 8 | The behaviour of this Sensors is based on the knowledge there 9 | is so far on the behaviour of the sensor. So if our knowledge 10 | is wrong, so is the behaviour of this sensor! Thus is always 11 | makes sensor to also test against a real sensor. 12 | """ 13 | 14 | def __init__(self, adapter='hci0', address_type=None): 15 | super(MockBackend, self).__init__(adapter, address_type) 16 | self.written_handles = [] 17 | self.expected_write_handles = set() 18 | self.override_read_handles = dict() 19 | self.is_available = True 20 | self.address_type = address_type 21 | 22 | def check_backend(self): 23 | """This backend is available when the field is set accordingly.""" 24 | return self.is_available 25 | 26 | def read_handle(self, handle): 27 | """Read one of the handles that are implemented.""" 28 | if handle in self.override_read_handles: 29 | return self.override_read_handles[handle] 30 | raise ValueError('handle not implemented in mockup') 31 | 32 | def write_handle(self, handle, value): 33 | """Writing handles just stores the results in a list.""" 34 | self.written_handles.append((handle, value)) 35 | return handle in self.expected_write_handles 36 | 37 | def wait_for_notification(self, handle, delegate, notification_timeout): 38 | """same as write_handle. Delegate is not used, yet.""" 39 | delegate.handleNotification(bytes([int(x, 16) for x in "54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00".split()])) 40 | return self.write_handle(handle, self._DATA_MODE_LISTEN) 41 | -------------------------------------------------------------------------------- /test/unit_tests/test_bluepy.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the bluepy backend.""" 2 | 3 | import unittest 4 | from unittest import mock 5 | from test import TEST_MAC 6 | from bluepy.btle import BTLEException 7 | from btlewrap.bluepy import BluepyBackend 8 | from btlewrap import BluetoothBackendException 9 | 10 | 11 | class TestBluepy(unittest.TestCase): 12 | """Unit tests for the bluepy backend.""" 13 | 14 | # pylint: disable=no-self-use 15 | 16 | @mock.patch('bluepy.btle.Peripheral') 17 | def test_configuration_default(self, mock_peripheral): 18 | """Test adapter name pattern parsing.""" 19 | backend = BluepyBackend() 20 | backend.connect(TEST_MAC) 21 | mock_peripheral.assert_called_with(TEST_MAC, addrType='public', iface=0) 22 | 23 | @mock.patch('bluepy.btle.Peripheral') 24 | def test_configuration_hci12(self, mock_peripheral): 25 | """Test adapter name pattern parsing.""" 26 | backend = BluepyBackend(adapter='hci12') 27 | backend.connect(TEST_MAC) 28 | mock_peripheral.assert_called_with(TEST_MAC, addrType='public', iface=12) 29 | 30 | @mock.patch('bluepy.btle.Peripheral') 31 | def test_configuration_invalid(self, _): 32 | """Test adapter name pattern parsing.""" 33 | backend = BluepyBackend(adapter='somestring') 34 | with self.assertRaises(BluetoothBackendException): 35 | backend.connect(TEST_MAC) 36 | 37 | def test_check_backend_ok(self): 38 | """Test check_backend successfully.""" 39 | self.assertTrue(BluepyBackend.check_backend()) 40 | 41 | @mock.patch('bluepy.btle.Peripheral') 42 | def test_connect_exception(self, mock_peripheral): 43 | """Test exception wrapping.""" 44 | mock_peripheral.side_effect = BTLEException('Test') 45 | backend = BluepyBackend() 46 | with self.assertRaises(BluetoothBackendException): 47 | backend.connect(TEST_MAC) 48 | 49 | @mock.patch('bluepy.btle.Peripheral') 50 | def test_wait_for_notification(self, mock_peripheral): 51 | """Test writing to a handle successfully.""" 52 | backend = BluepyBackend() 53 | backend.connect(TEST_MAC) 54 | self.assertTrue(backend.wait_for_notification(0xFF, None, 10)) 55 | mock_peripheral.assert_called_with(TEST_MAC, addrType='public', iface=0) 56 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | btlewrap 2 | ======== 3 | 4 | Bluetooth LowEnergy wrapper for different python backends. This gives you a nice API so that you can use different Bluetooth implementations on different platforms. 5 | 6 | This library was initially implemented as part of the `miflora `_ library, but then refactored out, so that it can be used on other projects as well. 7 | 8 | contribution 9 | ============ 10 | .. image:: https://travis-ci.org/ChristianKuehnel/btlewrap.svg?branch=master 11 | :target: https://travis-ci.org/ChristianKuehnel/btlewrap 12 | 13 | .. image:: https://coveralls.io/repos/github/ChristianKuehnel/btlewrap/badge.svg?branch=master 14 | :target: https://coveralls.io/github/ChristianKuehnel/btlewrap?branch=master 15 | 16 | Backends 17 | ======== 18 | As there is unfortunately no universally working Bluetooth Low Energy library for Python, the project currently 19 | offers support for three Bluetooth implementations: 20 | 21 | * bluepy library (recommended library) 22 | * bluez tools (via a wrapper around gatttool) 23 | * pygatt for Bluegiga BLED112-based devices 24 | 25 | bluepy 26 | ------ 27 | To use the `bluepy `_ library you have to install it on your machine, in most cases this can be done via: 28 | 29 | :: 30 | 31 | pip3 install bluepy 32 | 33 | This is the recommended backend to be used. In comparision to the gatttool wrapper, it is much faster in getting the data and also more stable. 34 | 35 | 36 | bluez/gatttool wrapper 37 | ---------------------- 38 | To use the bluez wrapper, you need to install the bluez tools on your machine. No additional python 39 | libraries are required. Some distrubutions moved the gatttool binary to a separate package. Make sure you have this 40 | binaray available on your machine. 41 | 42 | 43 | 44 | 45 | pygatt 46 | ------ 47 | If you have a Blue Giga based device that is supported by `pygatt `_, you have to 48 | install the bluepy library on your machine. In most cases this can be done via: 49 | 50 | :: 51 | 52 | pip3 install pygatt 53 | 54 | Usage 55 | ===== 56 | See the depending projects below on how to use the library. 57 | 58 | Depending projects 59 | ================== 60 | These projects are using btlewrap: 61 | 62 | * `miflora `_ 63 | * `mitemp `_ 64 | -------------------------------------------------------------------------------- /btlewrap/pygatt.py: -------------------------------------------------------------------------------- 1 | """Bluetooth backend for Blue Giga based bluetooth devices. 2 | 3 | This backend uses the pygatt API: https://github.com/peplin/pygatt 4 | """ 5 | from typing import Callable 6 | from btlewrap.base import AbstractBackend, BluetoothBackendException 7 | 8 | 9 | def wrap_exception(func: Callable) -> Callable: 10 | """Decorator to wrap pygatt exceptions into BluetoothBackendException.""" 11 | try: 12 | # only do the wrapping if pygatt is installed. 13 | # otherwise it's pointless anyway 14 | from pygatt.backends.bgapi.exceptions import BGAPIError 15 | from pygatt.exceptions import NotConnectedError 16 | except ImportError: 17 | return func 18 | 19 | def _func_wrapper(*args, **kwargs): 20 | try: 21 | return func(*args, **kwargs) 22 | except BGAPIError as exception: 23 | raise BluetoothBackendException() from exception 24 | except NotConnectedError as exception: 25 | raise BluetoothBackendException() from exception 26 | 27 | return _func_wrapper 28 | 29 | 30 | class PygattBackend(AbstractBackend): 31 | """Bluetooth backend for Blue Giga based bluetooth devices.""" 32 | 33 | @wrap_exception 34 | def __init__(self, adapter: str = None, address_type: str = 'public'): 35 | """Create a new instance. 36 | 37 | Note: the parameter "adapter" is ignored, pygatt detects the right USB port automagically. 38 | """ 39 | super(PygattBackend, self).__init__(adapter, address_type) 40 | self.check_backend() 41 | 42 | import pygatt 43 | self._adapter = pygatt.BGAPIBackend() 44 | self._adapter.start() 45 | self._device = None 46 | 47 | def __del__(self): 48 | if self._adapter is not None: 49 | self._adapter.stop() 50 | 51 | @wrap_exception 52 | def connect(self, mac: str): 53 | """Connect to a device.""" 54 | import pygatt 55 | 56 | address_type = pygatt.BLEAddressType.public 57 | if self.address_type == 'random': 58 | address_type = pygatt.BLEAddressType.random 59 | self._device = self._adapter.connect(mac, address_type=address_type) 60 | 61 | def is_connected(self) -> bool: 62 | """Check if connected to a device.""" 63 | return self._device is not None 64 | 65 | @wrap_exception 66 | def disconnect(self): 67 | """Disconnect from a device.""" 68 | if self.is_connected(): 69 | self._device.disconnect() 70 | self._device = None 71 | 72 | @wrap_exception 73 | def read_handle(self, handle: int) -> bytes: 74 | """Read a handle from the device.""" 75 | if not self.is_connected(): 76 | raise BluetoothBackendException('Not connected to device!') 77 | return self._device.char_read_handle(handle) 78 | 79 | @wrap_exception 80 | def write_handle(self, handle: int, value: bytes): 81 | """Write a handle to the device.""" 82 | if not self.is_connected(): 83 | raise BluetoothBackendException('Not connected to device!') 84 | self._device.char_write_handle(handle, value, True) 85 | return True 86 | 87 | @staticmethod 88 | def check_backend() -> bool: 89 | """Check if the backend is available.""" 90 | try: 91 | import pygatt # noqa: F401 # pylint: disable=unused-import 92 | return True 93 | except ImportError: 94 | return False 95 | -------------------------------------------------------------------------------- /btlewrap/bluepy.py: -------------------------------------------------------------------------------- 1 | """Backend for Miflora using the bluepy library.""" 2 | import re 3 | import logging 4 | import time 5 | from typing import List, Tuple, Callable 6 | from btlewrap.base import AbstractBackend, BluetoothBackendException 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | RETRY_LIMIT = 3 10 | RETRY_DELAY = 0.1 11 | 12 | 13 | def wrap_exception(func: Callable) -> Callable: 14 | """Decorator to wrap BTLEExceptions into BluetoothBackendException.""" 15 | try: 16 | # only do the wrapping if bluepy is installed. 17 | # otherwise it's pointless anyway 18 | from bluepy.btle import BTLEException 19 | except ImportError: 20 | return func 21 | 22 | def _func_wrapper(*args, **kwargs): 23 | error_count = 0 24 | last_error = None 25 | while error_count < RETRY_LIMIT: 26 | try: 27 | return func(*args, **kwargs) 28 | except BTLEException as exception: 29 | error_count += 1 30 | last_error = exception 31 | time.sleep(RETRY_DELAY) 32 | _LOGGER.debug('Call to %s failed, try %d of %d', func, error_count, RETRY_LIMIT) 33 | raise BluetoothBackendException() from last_error 34 | 35 | return _func_wrapper 36 | 37 | 38 | class BluepyBackend(AbstractBackend): 39 | """Backend for Miflora using the bluepy library.""" 40 | 41 | def __init__(self, adapter: str = 'hci0', address_type: str = 'public'): 42 | """Create new instance of the backend.""" 43 | super(BluepyBackend, self).__init__(adapter, address_type) 44 | self._peripheral = None 45 | 46 | @wrap_exception 47 | def connect(self, mac: str): 48 | """Connect to a device.""" 49 | from bluepy.btle import Peripheral 50 | match_result = re.search(r'hci([\d]+)', self.adapter) 51 | if match_result is None: 52 | raise BluetoothBackendException( 53 | 'Invalid pattern "{}" for BLuetooth adpater. ' 54 | 'Expetected something like "hci0".'.format(self.adapter)) 55 | iface = int(match_result.group(1)) 56 | self._peripheral = Peripheral(mac, iface=iface, addrType=self.address_type) 57 | 58 | @wrap_exception 59 | def disconnect(self): 60 | """Disconnect from a device if connected.""" 61 | if self._peripheral is None: 62 | return 63 | 64 | self._peripheral.disconnect() 65 | self._peripheral = None 66 | 67 | @wrap_exception 68 | def read_handle(self, handle: int) -> bytes: 69 | """Read a handle from the device. 70 | 71 | You must be connected to do this. 72 | """ 73 | if self._peripheral is None: 74 | raise BluetoothBackendException('not connected to backend') 75 | return self._peripheral.readCharacteristic(handle) 76 | 77 | @wrap_exception 78 | def write_handle(self, handle: int, value: bytes): 79 | """Write a handle from the device. 80 | 81 | You must be connected to do this. 82 | """ 83 | if self._peripheral is None: 84 | raise BluetoothBackendException('not connected to backend') 85 | return self._peripheral.writeCharacteristic(handle, value, True) 86 | 87 | @wrap_exception 88 | def wait_for_notification(self, handle: int, delegate, notification_timeout: float): 89 | if self._peripheral is None: 90 | raise BluetoothBackendException('not connected to backend') 91 | self.write_handle(handle, self._DATA_MODE_LISTEN) 92 | self._peripheral.withDelegate(delegate) 93 | return self._peripheral.waitForNotifications(notification_timeout) 94 | 95 | @staticmethod 96 | def check_backend() -> bool: 97 | """Check if the backend is available.""" 98 | try: 99 | import bluepy.btle # noqa: F401 #pylint: disable=unused-import 100 | return True 101 | except ImportError as importerror: 102 | _LOGGER.error('bluepy not found: %s', str(importerror)) 103 | return False 104 | 105 | @staticmethod 106 | @wrap_exception 107 | def scan_for_devices(timeout: float, adapter='hci0') -> List[Tuple[str, str]]: 108 | """Scan for bluetooth low energy devices. 109 | 110 | Note this must be run as root!""" 111 | from bluepy.btle import Scanner 112 | 113 | match_result = re.search(r'hci([\d]+)', adapter) 114 | if match_result is None: 115 | raise BluetoothBackendException( 116 | 'Invalid pattern "{}" for BLuetooth adpater. ' 117 | 'Expetected something like "hci0".'.format(adapter)) 118 | iface = int(match_result.group(1)) 119 | 120 | scanner = Scanner(iface=iface) 121 | result = [] 122 | for device in scanner.scan(timeout): 123 | result.append((device.addr, device.getValueText(9))) 124 | return result 125 | -------------------------------------------------------------------------------- /btlewrap/base.py: -------------------------------------------------------------------------------- 1 | """Bluetooth Backends available for miflora and other btle sensors.""" 2 | from threading import Lock 3 | from typing import List, Tuple 4 | 5 | 6 | class BluetoothInterface: 7 | """Wrapper around the bluetooth adapters. 8 | 9 | This class takes care of locking and the context managers. 10 | """ 11 | 12 | def __init__(self, backend: "AbstractBackend", *, adapter: str = 'hci0', address_type: str = 'public', **kwargs): 13 | self._backend = backend(adapter=adapter, address_type=address_type, **kwargs) 14 | self._backend.check_backend() 15 | 16 | def __del__(self): 17 | if self.is_connected(): 18 | self._backend.disconnect() 19 | 20 | def connect(self, mac) -> "_BackendConnection": 21 | """Connect to the sensor.""" 22 | return _BackendConnection(self._backend, mac) 23 | 24 | @staticmethod 25 | def is_connected() -> bool: 26 | """Check if we are connected to the sensor.""" 27 | return _BackendConnection.is_connected() 28 | 29 | 30 | class _BackendConnection: # pylint: disable=too-few-public-methods 31 | """Context Manager for a bluetooth connection. 32 | 33 | This creates the context for the connection and manages locking. 34 | """ 35 | 36 | _lock = Lock() 37 | 38 | def __init__(self, backend: "AbstractBackend", mac: str): 39 | self._backend = backend # type: AbstractBackend 40 | self._mac = mac # type: str 41 | self._has_lock = False 42 | 43 | def __enter__(self) -> "AbstractBackend": 44 | self._lock.acquire() 45 | self._has_lock = True 46 | try: 47 | self._backend.connect(self._mac) 48 | # release lock on any exceptions otherwise it will never be unlocked 49 | except: # noqa: E722 50 | self._cleanup() 51 | raise 52 | return self._backend 53 | 54 | def __exit__(self, exc_type, exc_val, exc_tb): 55 | self._cleanup() 56 | 57 | def __del__(self): 58 | self._cleanup() 59 | 60 | def _cleanup(self): 61 | if self._has_lock: 62 | self._backend.disconnect() 63 | self._lock.release() 64 | self._has_lock = False 65 | 66 | @staticmethod 67 | def is_connected() -> bool: 68 | """Check if the BackendConnection is connected.""" 69 | return _BackendConnection._lock.locked() # pylint: disable=no-member 70 | 71 | 72 | class BluetoothBackendException(Exception): 73 | """Exception thrown by the different backends. 74 | 75 | This is a wrapper for other exception specific to each library.""" 76 | 77 | 78 | class AbstractBackend: 79 | """Abstract base class for talking to Bluetooth LE devices. 80 | 81 | This class will be overridden by the different backends used by miflora and other btle sensors. 82 | """ 83 | 84 | _DATA_MODE_LISTEN = bytes([0x01, 0x00]) 85 | 86 | def __init__(self, adapter: str, address_type: str, **kwargs): 87 | self.adapter = adapter 88 | self.address_type = address_type 89 | self.kwargs = kwargs 90 | 91 | def connect(self, mac: str): 92 | """connect to a device with the given @mac. 93 | 94 | only required by some backends""" 95 | 96 | def disconnect(self): 97 | """disconnect from a device. 98 | 99 | Only required by some backends""" 100 | 101 | def write_handle(self, handle: int, value: bytes): 102 | """Write a value to a handle. 103 | 104 | You must be connected to a device first.""" 105 | raise NotImplementedError 106 | 107 | def wait_for_notification(self, handle: int, delegate, notification_timeout: float): 108 | """ registers as a listener and calls the delegate's handleNotification 109 | for each notification received 110 | @param handle - the handle to use to register for notifications 111 | @param delegate - the delegate object's handleNotification is called for every notification received 112 | @param notification_timeout - wait this amount of seconds for notifications 113 | 114 | """ 115 | raise NotImplementedError 116 | 117 | def read_handle(self, handle: int) -> bytes: 118 | """Read a handle from the sensor. 119 | 120 | You must be connected to a device first.""" 121 | raise NotImplementedError 122 | 123 | @staticmethod 124 | def check_backend() -> bool: 125 | """Check if the backend is available on the current system. 126 | 127 | Returns True if the backend is available and False otherwise 128 | """ 129 | raise NotImplementedError 130 | 131 | @staticmethod 132 | def scan_for_devices(timeout, adapter) -> List[Tuple[str, str]]: 133 | """Scan for additional devices. 134 | 135 | Returns a list of all the mac addresses of Xiaomi Mi Flower sensor that could be found. 136 | """ 137 | raise NotImplementedError 138 | -------------------------------------------------------------------------------- /test/unit_tests/test_gatttool.py: -------------------------------------------------------------------------------- 1 | """Test gatttool backend.""" 2 | 3 | import unittest 4 | from unittest import mock 5 | from test import TEST_MAC 6 | from subprocess import TimeoutExpired 7 | from btlewrap import GatttoolBackend, BluetoothBackendException 8 | 9 | 10 | class TestGatttool(unittest.TestCase): 11 | """Test gatttool by mocking gatttool. 12 | 13 | These tests do NOT require hardware! 14 | time.sleep is mocked in some cases to speed up the retry-feature. 15 | """ 16 | # access to protected members is fine in testing 17 | # pylint: disable = protected-access 18 | 19 | # arguments of mock.patch are not always used 20 | # pylint: disable = unused-argument 21 | 22 | handle_notification_called = False 23 | 24 | @mock.patch('btlewrap.gatttool.Popen') 25 | def test_read_handle_ok(self, popen_mock): 26 | """Test reading handle successfully.""" 27 | gattoutput = bytes([0x00, 0x11, 0xAA, 0xFF]) 28 | _configure_popenmock(popen_mock, 'Characteristic value/descriptor: 00 11 AA FF') 29 | backend = GatttoolBackend() 30 | backend.connect(TEST_MAC) 31 | result = backend.read_handle(0xFF) 32 | self.assertEqual(gattoutput, result) 33 | 34 | def test_run_connect_disconnect(self): 35 | """Just run connect and disconnect""" 36 | backend = GatttoolBackend() 37 | backend.connect(TEST_MAC) 38 | self.assertEqual(TEST_MAC, backend._mac) 39 | backend.disconnect() 40 | self.assertEqual(None, backend._mac) 41 | 42 | @mock.patch('btlewrap.gatttool.Popen') 43 | @mock.patch('time.sleep', return_value=None) 44 | def test_read_handle_empty_output(self, _, popen_mock): 45 | """Test reading handle where no result is returned.""" 46 | _configure_popenmock(popen_mock, '') 47 | backend = GatttoolBackend() 48 | backend.connect(TEST_MAC) 49 | with self.assertRaises(BluetoothBackendException): 50 | backend.read_handle(0xFF) 51 | 52 | @mock.patch('btlewrap.gatttool.Popen') 53 | def test_read_handle_wrong_handle(self, popen_mock): 54 | """Test reading invalid handle.""" 55 | _configure_popenmock(popen_mock, 'Characteristic value/descriptor read failed: Invalid handle') 56 | backend = GatttoolBackend() 57 | backend.connect(TEST_MAC) 58 | with self.assertRaises(BluetoothBackendException): 59 | backend.read_handle(0xFF) 60 | 61 | def test_read_not_connected(self): 62 | """Test reading data when not connected.""" 63 | backend = GatttoolBackend() 64 | with self.assertRaises(BluetoothBackendException): 65 | backend.read_handle(0xFF) 66 | 67 | @mock.patch('os.killpg') 68 | @mock.patch('btlewrap.gatttool.Popen') 69 | @mock.patch('time.sleep', return_value=None) 70 | def test_read_handle_timeout(self, time_mock, popen_mock, os_mock): 71 | """Test notification when timeout""" 72 | _configure_popenmock_timeout(popen_mock, "Characteristic") 73 | backend = GatttoolBackend() 74 | backend.connect(TEST_MAC) 75 | with self.assertRaises(BluetoothBackendException): 76 | backend.read_handle(0xFF) 77 | 78 | def test_write_not_connected(self): 79 | """Test writing data when not connected.""" 80 | backend = GatttoolBackend() 81 | with self.assertRaises(BluetoothBackendException): 82 | backend.write_handle(0xFF, [0x00]) 83 | 84 | @mock.patch('btlewrap.gatttool.Popen') 85 | @mock.patch('time.sleep', return_value=None) 86 | def test_write_handle_ok(self, time_mock, popen_mock): 87 | """Test writing to a handle successfully.""" 88 | _configure_popenmock(popen_mock, 'Characteristic value was written successfully') 89 | backend = GatttoolBackend() 90 | backend.connect(TEST_MAC) 91 | self.assertTrue(backend.write_handle(0xFF, b'\x00\x10\xFF')) 92 | 93 | @mock.patch('btlewrap.gatttool.Popen') 94 | @mock.patch('time.sleep', return_value=None) 95 | def test_write_handle_wrong_handle(self, time_mock, popen_mock): 96 | """Test writing to a non-writable handle.""" 97 | _configure_popenmock(popen_mock, "Characteristic Write Request failed: Attribute can't be written") 98 | backend = GatttoolBackend() 99 | backend.connect(TEST_MAC) 100 | with self.assertRaises(BluetoothBackendException): 101 | backend.write_handle(0xFF, b'\x00\x10\xFF') 102 | 103 | @mock.patch('btlewrap.gatttool.Popen') 104 | @mock.patch('time.sleep', return_value=None) 105 | def test_write_handle_no_answer(self, time_mock, popen_mock): 106 | """Test writing to a handle when no result is returned.""" 107 | _configure_popenmock(popen_mock, '') 108 | backend = GatttoolBackend() 109 | backend.connect(TEST_MAC) 110 | with self.assertRaises(BluetoothBackendException): 111 | backend.write_handle(0xFF, b'\x00\x10\xFF') 112 | 113 | @mock.patch('os.killpg') 114 | @mock.patch('btlewrap.gatttool.Popen') 115 | @mock.patch('time.sleep', return_value=None) 116 | def test_write_handle_timeout(self, time_mock, popen_mock, os_mock): 117 | """Test notification when timeout""" 118 | _configure_popenmock_timeout(popen_mock, "Characteristic") 119 | backend = GatttoolBackend() 120 | backend.connect(TEST_MAC) 121 | with self.assertRaises(BluetoothBackendException): 122 | backend.write_handle(0xFF, b'\x00\x10\xFF') 123 | 124 | def test_notification_not_connected(self): 125 | """Test writing data when not connected.""" 126 | backend = GatttoolBackend() 127 | with self.assertRaises(BluetoothBackendException): 128 | backend.wait_for_notification(0xFF, self, 10) 129 | 130 | @mock.patch('btlewrap.gatttool.Popen') 131 | @mock.patch('time.sleep', return_value=None) 132 | def test_wait_for_notification(self, time_mock, popen_mock): 133 | """Test notification successfully.""" 134 | _configure_popenmock(popen_mock, ( 135 | "Characteristic value was written successfully\n" 136 | "Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00\n" 137 | "Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00\n" 138 | "Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00") 139 | ) 140 | backend = GatttoolBackend() 141 | backend.connect(TEST_MAC) 142 | self.handle_notification_called = False 143 | self.assertTrue(backend.wait_for_notification(0xFF, self, 10)) 144 | self.assertTrue(self.handle_notification_called) 145 | 146 | def handleNotification(self, handle, raw_data): # pylint: disable=unused-argument,invalid-name,no-self-use 147 | """ gets called by the backend when using wait_for_notification 148 | """ 149 | if raw_data is None: 150 | raise Exception('no data given') 151 | self.assertTrue(len(raw_data) == 14) 152 | self.handle_notification_called = True 153 | 154 | @mock.patch('btlewrap.gatttool.Popen') 155 | @mock.patch('time.sleep', return_value=None) 156 | def test_notification_wrong_handle(self, time_mock, popen_mock): 157 | """Test notification when wrong handle""" 158 | _configure_popenmock(popen_mock, "Characteristic Write Request failed: Attribute can't be written") 159 | backend = GatttoolBackend() 160 | backend.connect(TEST_MAC) 161 | with self.assertRaises(BluetoothBackendException): 162 | backend.wait_for_notification(0xFF, self, 10) 163 | 164 | @mock.patch('btlewrap.gatttool.Popen') 165 | @mock.patch('time.sleep', return_value=None) 166 | def test_notification_no_answer(self, time_mock, popen_mock): 167 | """Test notification when no result is returned.""" 168 | _configure_popenmock(popen_mock, '') 169 | backend = GatttoolBackend() 170 | backend.connect(TEST_MAC) 171 | with self.assertRaises(BluetoothBackendException): 172 | backend.wait_for_notification(0xFF, self, 10) 173 | 174 | @mock.patch('os.killpg') 175 | @mock.patch('btlewrap.gatttool.Popen') 176 | @mock.patch('time.sleep', return_value=None) 177 | def test_notification_timeout(self, time_mock, popen_mock, os_mock): 178 | """Test notification when timeout""" 179 | _configure_popenmock_timeout(popen_mock, ( 180 | "Characteristic value was written successfully\n" 181 | "Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00\n" 182 | "Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00\n" 183 | "Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00")) 184 | backend = GatttoolBackend() 185 | backend.connect(TEST_MAC) 186 | self.handle_notification_called = False 187 | self.assertTrue(backend.wait_for_notification(0xFF, self, 10)) 188 | self.assertTrue(self.handle_notification_called) 189 | 190 | @mock.patch('btlewrap.gatttool.call', return_value=None) 191 | def test_check_backend_ok(self, call_mock): 192 | """Test check_backend successfully.""" 193 | self.assertTrue(GatttoolBackend().check_backend()) 194 | 195 | @mock.patch('btlewrap.gatttool.call', **{'side_effect': IOError()}) 196 | def test_check_backend_fail(self, call_mock): 197 | """Test check_backend with IOError being risen.""" 198 | self.assertFalse(GatttoolBackend().check_backend()) 199 | 200 | def test_notification_payload_ok(self): 201 | """ testing data processing""" 202 | 203 | notification_response = ( 204 | "Characteristic value was written successfully\n" 205 | "Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00\n" 206 | "Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00\n" 207 | "Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00") 208 | data = GatttoolBackend().extract_notification_payload(notification_response) 209 | self.assertTrue(len(data) == 3) 210 | self.assertTrue(data[0] == "54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00") 211 | self.assertTrue(data[2] == "54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00") 212 | 213 | 214 | def _configure_popenmock(popen_mock, output_string): 215 | """Helper function to create a mock for Popen.""" 216 | match_result = mock.Mock() 217 | match_result.communicate.return_value = [ 218 | bytes(output_string, encoding='UTF-8'), 219 | bytes('random text', encoding='UTF-8')] 220 | popen_mock.return_value.__enter__.return_value = match_result 221 | 222 | 223 | def _configure_popenmock_timeout(popen_mock, output_string): 224 | """Helper function to create a mock for Popen.""" 225 | match_result = mock.Mock() 226 | match_result.communicate = POpenHelper(output_string).communicate_timeout 227 | popen_mock.return_value.__enter__.return_value = match_result 228 | 229 | 230 | class POpenHelper: 231 | """Helper class to configure Popen mock behavior for timeout""" 232 | partial_response = '' 233 | 234 | def __init__(self, output_string=None): 235 | self.set_partial_response(output_string) 236 | 237 | def set_partial_response(self, response): 238 | """set response on timeout""" 239 | self.partial_response = response 240 | 241 | def communicate_timeout(self, timeout=None): # pylint: disable=no-self-use,unused-argument 242 | """pass this method as a replacement to themocked Popen.communicate method""" 243 | process = mock.Mock() 244 | process.pid = 0 245 | if timeout: 246 | raise TimeoutExpired(process, timeout) 247 | return [bytes(self.partial_response, 'utf-8')] 248 | -------------------------------------------------------------------------------- /btlewrap/gatttool.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reading from the sensor is handled by the command line tool "gatttool" that 3 | is part of bluez on Linux. 4 | No other operating systems are supported at the moment 5 | """ 6 | 7 | from threading import current_thread 8 | import os 9 | import logging 10 | import re 11 | import time 12 | from typing import Callable 13 | from subprocess import Popen, PIPE, TimeoutExpired, signal, call 14 | from btlewrap.base import AbstractBackend, BluetoothBackendException 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | def wrap_exception(func: Callable) -> Callable: 20 | """Wrap all IOErrors to BluetoothBackendException""" 21 | 22 | def _func_wrapper(*args, **kwargs): 23 | try: 24 | return func(*args, **kwargs) 25 | except IOError as exception: 26 | raise BluetoothBackendException() from exception 27 | return _func_wrapper 28 | 29 | 30 | class GatttoolBackend(AbstractBackend): 31 | """ Backend using gatttool.""" 32 | 33 | # pylint: disable=subprocess-popen-preexec-fn 34 | 35 | def __init__(self, adapter: str = 'hci0', *, retries: int = 3, timeout: float = 20, address_type: str = 'public'): 36 | super(GatttoolBackend, self).__init__(adapter, address_type) 37 | self.adapter = adapter 38 | self.retries = retries 39 | self.timeout = timeout 40 | self.address_type = address_type 41 | self._mac = None 42 | 43 | def connect(self, mac: str): 44 | """Connect to sensor. 45 | 46 | Connection handling is not required when using gatttool, but we still need the mac 47 | """ 48 | self._mac = mac 49 | 50 | def disconnect(self): 51 | """Disconnect from sensor. 52 | 53 | Connection handling is not required when using gatttool. 54 | """ 55 | self._mac = None 56 | 57 | def is_connected(self) -> bool: 58 | """Check if we are connected to the backend.""" 59 | return self._mac is not None 60 | 61 | @wrap_exception 62 | def write_handle(self, handle: int, value: bytes): 63 | # noqa: C901 64 | # pylint: disable=arguments-differ 65 | 66 | """Read from a BLE address. 67 | 68 | @param: mac - MAC address in format XX:XX:XX:XX:XX:XX 69 | @param: handle - BLE characteristics handle in format 0xXX 70 | @param: value - value to write to the given handle 71 | """ 72 | 73 | if not self.is_connected(): 74 | raise BluetoothBackendException('Not connected to any device.') 75 | 76 | attempt = 0 77 | delay = 10 78 | _LOGGER.debug("Enter write_ble (%s)", current_thread()) 79 | 80 | while attempt <= self.retries: 81 | cmd = "gatttool --device={} --addr-type={} --char-write-req -a {} -n {} --adapter={}".format( 82 | self._mac, self.address_type, self.byte_to_handle(handle), self.bytes_to_string(value), self.adapter) 83 | _LOGGER.debug("Running gatttool with a timeout of %d: %s", 84 | self.timeout, cmd) 85 | 86 | with Popen(cmd, 87 | shell=True, 88 | stdout=PIPE, 89 | stderr=PIPE, 90 | preexec_fn=os.setsid) as process: 91 | try: 92 | result = process.communicate(timeout=self.timeout)[0] 93 | _LOGGER.debug("Finished gatttool") 94 | except TimeoutExpired: 95 | # send signal to the process group 96 | os.killpg(process.pid, signal.SIGINT) 97 | result = process.communicate()[0] 98 | _LOGGER.debug("Killed hanging gatttool") 99 | 100 | result = result.decode("utf-8").strip(' \n\t') 101 | if "Write Request failed" in result: 102 | raise BluetoothBackendException('Error writing handle to sensor: {}'.format(result)) 103 | _LOGGER.debug("Got %s from gatttool", result) 104 | # Parse the output 105 | if "successfully" in result: 106 | _LOGGER.debug( 107 | "Exit write_ble with result (%s)", current_thread()) 108 | return True 109 | 110 | attempt += 1 111 | _LOGGER.debug("Waiting for %s seconds before retrying", delay) 112 | if attempt < self.retries: 113 | time.sleep(delay) 114 | delay *= 2 115 | 116 | raise BluetoothBackendException("Exit write_ble, no data ({})".format(current_thread())) 117 | 118 | @wrap_exception 119 | def wait_for_notification(self, handle: int, delegate, notification_timeout: float): 120 | """Listen for characteristics changes from a BLE address. 121 | 122 | @param: mac - MAC address in format XX:XX:XX:XX:XX:XX 123 | @param: handle - BLE characteristics handle in format 0xXX 124 | a value of 0x0100 is written to register for listening 125 | @param: delegate - gatttool receives the 126 | --listen argument and the delegate object's handleNotification is 127 | called for every returned row 128 | @param: notification_timeout 129 | """ 130 | 131 | if not self.is_connected(): 132 | raise BluetoothBackendException('Not connected to any device.') 133 | 134 | attempt = 0 135 | delay = 10 136 | _LOGGER.debug("Enter write_ble (%s)", current_thread()) 137 | 138 | while attempt <= self.retries: 139 | cmd = "gatttool --device={} --addr-type={} --char-write-req -a {} -n {} --adapter={} --listen".format( 140 | self._mac, self.address_type, self.byte_to_handle(handle), self.bytes_to_string(self._DATA_MODE_LISTEN), 141 | self.adapter) 142 | _LOGGER.debug("Running gatttool with a timeout of %d: %s", notification_timeout, cmd) 143 | 144 | with Popen(cmd, 145 | shell=True, 146 | stdout=PIPE, 147 | stderr=PIPE, 148 | preexec_fn=os.setsid) as process: 149 | try: 150 | result = process.communicate(timeout=notification_timeout)[0] 151 | _LOGGER.debug("Finished gatttool") 152 | except TimeoutExpired: 153 | # send signal to the process group, because listening always hangs 154 | os.killpg(process.pid, signal.SIGINT) 155 | result = process.communicate()[0] 156 | _LOGGER.debug("Listening stopped forcefully after timeout.") 157 | 158 | result = result.decode("utf-8").strip(' \n\t') 159 | if "Write Request failed" in result: 160 | raise BluetoothBackendException('Error writing handle to sensor: {}'.format(result)) 161 | _LOGGER.debug("Got %s from gatttool", result) 162 | # Parse the output to determine success 163 | if "successfully" in result: 164 | _LOGGER.debug("Exit write_ble with result (%s)", current_thread()) 165 | # extract useful data. 166 | for element in self.extract_notification_payload(result): 167 | delegate.handleNotification(handle, bytes([int(x, 16) for x in element.split()])) 168 | return True 169 | 170 | attempt += 1 171 | _LOGGER.debug("Waiting for %s seconds before retrying", delay) 172 | if attempt < self.retries: 173 | time.sleep(delay) 174 | delay *= 2 175 | 176 | raise BluetoothBackendException("Exit write_ble, no data ({})".format(current_thread())) 177 | 178 | @staticmethod 179 | def extract_notification_payload(process_output): 180 | """ 181 | Processes the raw output from Gatttool stripping the first line and the 182 | 'Notification handle = 0x000e value: ' from each line 183 | @param: process_output - the raw output from a listen commad of GattTool 184 | which may look like this: 185 | Characteristic value was written successfully 186 | Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00 187 | Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00 188 | Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 31 00 189 | Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 33 00 190 | Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 31 00 191 | Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00 192 | 193 | 194 | This method strips the fist line and strips the 'Notification handle = 0x000e value: ' from each line 195 | @returns a processed string only containing the values. 196 | """ 197 | data = [] 198 | for element in process_output.splitlines()[1:]: 199 | parts = element.split(": ") 200 | if len(parts) == 2: 201 | data.append(parts[1]) 202 | return data 203 | 204 | @wrap_exception 205 | def read_handle(self, handle: int) -> bytes: 206 | """Read from a BLE address. 207 | 208 | @param: mac - MAC address in format XX:XX:XX:XX:XX:XX 209 | @param: handle - BLE characteristics handle in format 0xXX 210 | @param: timeout - timeout in seconds 211 | """ 212 | 213 | if not self.is_connected(): 214 | raise BluetoothBackendException('Not connected to any device.') 215 | 216 | attempt = 0 217 | delay = 10 218 | _LOGGER.debug("Enter read_ble (%s)", current_thread()) 219 | 220 | while attempt <= self.retries: 221 | cmd = "gatttool --device={} --addr-type={} --char-read -a {} --adapter={}".format( 222 | self._mac, self.address_type, self.byte_to_handle(handle), self.adapter) 223 | _LOGGER.debug("Running gatttool with a timeout of %d: %s", 224 | self.timeout, cmd) 225 | with Popen(cmd, 226 | shell=True, 227 | stdout=PIPE, 228 | stderr=PIPE, 229 | preexec_fn=os.setsid) as process: 230 | try: 231 | result = process.communicate(timeout=self.timeout)[0] 232 | _LOGGER.debug("Finished gatttool") 233 | except TimeoutExpired: 234 | # send signal to the process group 235 | os.killpg(process.pid, signal.SIGINT) 236 | result = process.communicate()[0] 237 | _LOGGER.debug("Killed hanging gatttool") 238 | 239 | result = result.decode("utf-8").strip(' \n\t') 240 | _LOGGER.debug("Got \"%s\" from gatttool", result) 241 | # Parse the output 242 | if "read failed" in result: 243 | raise BluetoothBackendException("Read error from gatttool: {}".format(result)) 244 | 245 | res = re.search("( [0-9a-fA-F][0-9a-fA-F])+", result) 246 | if res: 247 | _LOGGER.debug( 248 | "Exit read_ble with result (%s)", current_thread()) 249 | return bytes([int(x, 16) for x in res.group(0).split()]) 250 | 251 | attempt += 1 252 | _LOGGER.debug("Waiting for %s seconds before retrying", delay) 253 | if attempt < self.retries: 254 | time.sleep(delay) 255 | delay *= 2 256 | 257 | raise BluetoothBackendException("Exit read_ble, no data ({})".format(current_thread())) 258 | 259 | @staticmethod 260 | def check_backend() -> bool: 261 | """Check if gatttool is available on the system.""" 262 | try: 263 | call('gatttool', stdout=PIPE, stderr=PIPE) 264 | return True 265 | except OSError as os_err: 266 | msg = 'gatttool not found: {}'.format(str(os_err)) 267 | _LOGGER.error(msg) 268 | return False 269 | 270 | @staticmethod 271 | def byte_to_handle(in_byte: int) -> str: 272 | """Convert a byte array to a handle string.""" 273 | return '0x'+'{:02x}'.format(in_byte).upper() 274 | 275 | @staticmethod 276 | def bytes_to_string(raw_data: bytes, prefix: bool = False) -> str: 277 | """Convert a byte array to a hex string.""" 278 | prefix_string = '' 279 | if prefix: 280 | prefix_string = '0x' 281 | suffix = ''.join([format(c, "02x") for c in raw_data]) 282 | return prefix_string + suffix.upper() 283 | --------------------------------------------------------------------------------