├── test ├── no_imports │ ├── __init__.py │ └── test_noimports.py ├── unit_tests │ ├── __init__.py │ ├── test_pygatt.py │ ├── test_versioncheck.py │ ├── test_bluetooth_interface.py │ ├── test_available_backends.py │ ├── test_bluepy.py │ ├── test_gatttoolhelper.py │ └── test_gatttool.py ├── __init__.py ├── integration_tests │ ├── test_bluepy.py │ ├── test_pygatt.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 ├── .git-blame-ignore-revs ├── requirements-test.txt ├── pylintrc ├── .github └── workflows │ ├── black.yml │ └── tox.yml ├── release.sh ├── .coveragerc ├── run_integration_tests ├── LICENSE ├── tox.ini ├── setup.py ├── .gitignore └── README.rst /test/no_imports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bluepy 2 | pygatt 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.1.2-dev" 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # re-formatted the code base with black 2 | a57981752513b21b43ce7d6b764ccff0d0d5a7e7 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-timeout 4 | pylint==2.4.2 5 | flake8 6 | pexpect 7 | pytype 8 | black 9 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = abstract-method, too-many-instance-attributes, too-many-arguments, 3 | bad-whitespace, import-outside-toplevel, bad-continuation 4 | 5 | [FORMAT] 6 | max-line-length=120 -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/unit_tests/test_pygatt.py: -------------------------------------------------------------------------------- 1 | """Test pygatt backend.""" 2 | 3 | import unittest 4 | from btlewrap import PygattBackend 5 | 6 | 7 | class TestGatttool(unittest.TestCase): 8 | """Test gatttool by mocking gatttool. 9 | 10 | These tests do NOT require hardware! 11 | time.sleep is mocked in some cases to speed up the retry-feature.""" 12 | 13 | def test_supports_scanning(self): 14 | """Check if scanning is set correctly.""" 15 | self.assertFalse(PygattBackend.supports_scanning()) 16 | -------------------------------------------------------------------------------- /test/integration_tests/test_bluepy.py: -------------------------------------------------------------------------------- 1 | """Test btlewrap by connecting to a real device.""" 2 | 3 | import unittest 4 | from btlewrap import BluepyBackend 5 | from . import CommonTests 6 | 7 | 8 | class TestBluepy(unittest.TestCase, CommonTests): 9 | """Test btlewrap by connecting to a real device.""" 10 | 11 | # pylint does not understand pytest fixtures, so we have to disable the warning 12 | # pylint: disable=no-member 13 | 14 | def setUp(self): 15 | """Set up the test environment.""" 16 | self.backend = BluepyBackend() 17 | -------------------------------------------------------------------------------- /test/integration_tests/test_pygatt.py: -------------------------------------------------------------------------------- 1 | """Test btlewrap by connecting to a real device.""" 2 | 3 | import unittest 4 | from btlewrap import PygattBackend 5 | from . import CommonTests 6 | 7 | 8 | class TestBluepy(unittest.TestCase, CommonTests): 9 | """Test btlewrap by connecting to a real device.""" 10 | 11 | # pylint does not understand pytest fixtures, so we have to disable the warning 12 | # pylint: disable=no-member 13 | 14 | def setUp(self): 15 | """Set up the test environment.""" 16 | self.backend = PygattBackend() 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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( 14 | "--mac", action="store", help="mac address of sensor to be used for testing" 15 | ) 16 | logging.basicConfig(level=logging.DEBUG) 17 | 18 | 19 | @pytest.fixture(scope="class") 20 | def mac(request): 21 | """Get command line parameter and store it in class""" 22 | request.cls.mac = request.config.getoption("--mac") 23 | -------------------------------------------------------------------------------- /run_integration_tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | # simple script to run the integration tests using a Xiaomi miflora device 4 | # 5 | # - tests need to run as root so we can scan for new devices 6 | # - store the mac address of the Miflora 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 | 18 | # run tox with sudo to be able to scan for devices 19 | # run in separate folder so avoid later issues on file permissions 20 | sudo ${TOX} -e integration_tests --workdir=.tox_sudo -- --mac=$MAC $SUITE 21 | 22 | # reset permissions after running as root 23 | sudo chown -R $USER * .* -------------------------------------------------------------------------------- /test/integration_tests/test_gatttool.py: -------------------------------------------------------------------------------- 1 | """Test btlewrap by connecting to a real device.""" 2 | 3 | import unittest 4 | from btlewrap import GatttoolBackend 5 | from . import CommonTests 6 | 7 | 8 | class TestGatttool(unittest.TestCase, CommonTests): 9 | """Test btlewrap by connecting to a real device.""" 10 | 11 | # pylint does not understand pytest fixtures, so we have to disable the warning 12 | # pylint: disable=no-member 13 | 14 | def setUp(self): 15 | """Set up the test environment.""" 16 | self.backend = GatttoolBackend() 17 | 18 | def test_scan_with_adapter(self): 19 | """Scan for devices with specific adapter.""" 20 | 21 | devices = self.backend.scan_for_devices(timeout=7, adapter="hci0") 22 | self.assertGreater(len(devices), 0) 23 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: tox 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: '27 3 * * 6' 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | max-parallel: 4 18 | matrix: 19 | python-version: [3.6, 3.7, 3.8, 3.9, '3.10'] 20 | 21 | steps: 22 | - uses: actions/checkout@v1 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install tox tox-gh-actions 31 | - name: Test with tox 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: tox 35 | -------------------------------------------------------------------------------- /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 | 12 | MIN_SUPPORTED_VERSION = (3, 4) 13 | 14 | def test_py2(self): 15 | """Make sure older python versions throw an exception.""" 16 | if sys.version_info >= self.MIN_SUPPORTED_VERSION: 17 | return 18 | try: 19 | import btlewrap # noqa: F401 # pylint: disable=unused-import 20 | 21 | self.fail("Should have thrown an exception") 22 | except ValueError as val_err: 23 | self.assertIn("version", str(val_err)) 24 | 25 | def test_py3(self): 26 | """Make sure newer python versions do not throw an exception.""" 27 | if sys.version_info < self.MIN_SUPPORTED_VERSION: 28 | return 29 | import btlewrap # noqa: F401 # pylint: disable=unused-import 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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( 9 | "this library requires at least Python 3.4. " 10 | + "You're running version {}.{} from {}.".format( 11 | sys.version_info.major, sys.version_info.minor, sys.executable 12 | ) 13 | ) 14 | 15 | # pylint: disable=wrong-import-position 16 | from btlewrap.base import ( # noqa: F401,E402 17 | BluetoothBackendException, 18 | ) 19 | 20 | from btlewrap.bluepy import ( 21 | BluepyBackend, 22 | ) 23 | from btlewrap.gatttool import ( 24 | GatttoolBackend, 25 | ) 26 | from btlewrap.pygatt import ( 27 | PygattBackend, 28 | ) 29 | 30 | 31 | _ALL_BACKENDS = [BluepyBackend, GatttoolBackend, PygattBackend] 32 | 33 | 34 | def available_backends(): 35 | """Returns a list of all available backends.""" 36 | return [b for b in _ALL_BACKENDS if b.check_backend()] 37 | -------------------------------------------------------------------------------- /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.run", 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.run", **{"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 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38, py39, py310, pylint, flake8, noimport, pytype 3 | skip_missing_interpreters = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.6: py36, flake8, pylint, noimport 8 | 3.7: py37 9 | 3.8: py38 10 | 3.9: py39, pre-commit, pytype 11 | 3.10: py310 12 | 13 | [testenv] 14 | # only run unit tests as they do not need additional hardware 15 | deps = -rrequirements.txt 16 | -rrequirements-test.txt 17 | passenv = TRAVIS TRAVIS_* 18 | commands = pytest --cov=btlewrap --timeout=10 test/unit_tests 19 | 20 | [testenv:noimport] 21 | # run tests without installing any Bluetooth libraries 22 | deps= -rrequirements-test.txt 23 | commands = pytest --timeout=10 test/no_imports 24 | 25 | [testenv:integration_tests] 26 | #there tests are run separately as they rquire read hardware 27 | #need the command line argument --mac= to work 28 | commands = pytest --timeout=10 {posargs} 29 | 30 | [flake8] 31 | max-complexity = 10 32 | install-hook=git 33 | max-line-length=120 34 | 35 | [testenv:flake8] 36 | base=python3 37 | ignore_errors=True 38 | commands=flake8 test btlewrap 39 | 40 | [testenv:pylint] 41 | basepython = python3 42 | skip_install = true 43 | commands = pylint -j4 btlewrap test 44 | 45 | [testenv:pytype] 46 | commands = pytype --jobs 2 btlewrap 47 | -------------------------------------------------------------------------------- /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.6", 29 | "Programming Language :: Python :: 3.7", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | ], 34 | packages=find_packages(exclude=("test", "test.*")), 35 | keywords="bluetooth low-energy ble", 36 | zip_safe=False, 37 | extras_require={ 38 | "testing": ["pytest"], 39 | "bluepy": ["bluepy"], 40 | "pygatt": ["pygatt"], 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /test/integration_tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Common functionality for integration tests""" 2 | import pytest 3 | from btlewrap import BluepyBackend 4 | 5 | 6 | class CommonTests: 7 | """Base class for integration tests""" 8 | 9 | # pylint does not understand pytest fixtures, so we have to disable the warning 10 | # pylint: disable=no-member 11 | 12 | def setUp(self): # pylint: disable=invalid-name 13 | """Just create type definition for self.backend.""" 14 | self.backend = None # type: BluepyBackend 15 | raise NotImplementedError() 16 | 17 | def test_check_backend(self): 18 | """Ensure backend is available.""" 19 | self.assertTrue(self.backend.check_backend()) 20 | 21 | @pytest.mark.usefixtures("mac") 22 | def test_scan(self): 23 | """Scan for devices, if supported by backend.""" 24 | if not self.backend.supports_scanning(): 25 | return 26 | 27 | devices = self.backend.scan_for_devices(timeout=7) 28 | mac_list = [d[0].lower() for d in devices] 29 | self.assertIn(self.mac.lower(), mac_list) 30 | 31 | @pytest.mark.usefixtures("mac") 32 | def test_connect(self): 33 | """Try connecting to a device.""" 34 | self.backend.connect(self.mac) 35 | self.backend.disconnect() 36 | 37 | @pytest.mark.usefixtures("mac") 38 | def test_read_0x38(self): 39 | """Read from handle 0x38. 40 | 41 | On the miflora devices this get the battery and version info.""" 42 | self.backend.connect(self.mac) 43 | result = self.backend.read_handle(0x38) 44 | self.assertIsNotNone(result) 45 | self.assertGreater(len(result), 5) 46 | -------------------------------------------------------------------------------- /.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 | .tox_sudo 105 | .pytest_cache/ 106 | *.swp 107 | .test_mac 108 | 109 | #vscode 110 | .vscode/ -------------------------------------------------------------------------------- /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( 40 | bytes( 41 | [ 42 | int(x, 16) 43 | for x in "54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00".split() 44 | ] 45 | ) 46 | ) 47 | return self.write_handle(handle, self._DATA_MODE_LISTEN) 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 57 | def test_supports_scanning(self): 58 | """Check if scanning is set correctly.""" 59 | backend = BluepyBackend() 60 | self.assertTrue(backend.supports_scanning()) 61 | -------------------------------------------------------------------------------- /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 | # allow testsing protected member functions 11 | # pylint: disable = protected-access 12 | 13 | def test_byte_to_handle(self): 14 | """Test conversion of handles.""" 15 | self.assertEqual("0x0B", GatttoolBackend.byte_to_handle(0x0B)) 16 | self.assertEqual("0xAF", GatttoolBackend.byte_to_handle(0xAF)) 17 | self.assertEqual("0xAABB", GatttoolBackend.byte_to_handle(0xAABB)) 18 | 19 | def test_bytes_to_string(self): 20 | """Test conversion of byte arrays.""" 21 | self.assertEqual("0A0B", GatttoolBackend.bytes_to_string(bytes([0x0A, 0x0B]))) 22 | self.assertEqual( 23 | "0x0C0D", GatttoolBackend.bytes_to_string(bytes([0x0C, 0x0D]), True) 24 | ) 25 | 26 | def test_parse_scan_output_deduplicate(self): 27 | """Check if parsed lists are de-duplicated.""" 28 | test_data = """ 29 | LE Scan ... 30 | 65:B8:8C:38:D5:77 (MyDevice) 31 | 78:24:AC:37:21:3D (unknown) 32 | 78:24:AC:37:21:3D (unknown) 33 | 78:24:AC:37:21:3D (unknown) 34 | 63:82:9D:D1:B3:A2 (unknown) 35 | 63:82:9D:D1:B3:A2 (unknown) 36 | """ 37 | expected = [ 38 | ("65:B8:8C:38:D5:77", "MyDevice"), 39 | ("78:24:AC:37:21:3D", "unknown"), 40 | ("63:82:9D:D1:B3:A2", "unknown"), 41 | ] 42 | self.assertCountEqual(expected, GatttoolBackend._parse_scan_output(test_data)) 43 | 44 | def test_parse_scan_output_update_names(self): 45 | """Check if "unknown" names are updated later on.""" 46 | test_data = """ 47 | LE Scan ... 48 | 78:24:AC:37:21:3D (SomeDevice) 49 | 78:24:AC:37:21:3D (unknown) 50 | 63:82:9D:D1:B3:A2 (unknown) 51 | 63:82:9D:D1:B3:A2 (OtherDevice) 52 | """ 53 | expected = [ 54 | ("78:24:AC:37:21:3D", "SomeDevice"), 55 | ("63:82:9D:D1:B3:A2", "OtherDevice"), 56 | ] 57 | self.assertCountEqual(expected, GatttoolBackend._parse_scan_output(test_data)) 58 | 59 | def test_parse_scan_output_partial_data(self): 60 | """Check if the parser can handle partial data""" 61 | test_data = """ 62 | LE Scan ... 63 | 78:24:AC:37:21:3D (SomeDevice) 64 | 65:B8:8C:38:D5:77 (unknown) 65 | 78:24:AC:37:21:3D (unknown) 66 | 63:82:9D:D1:B3:A2 (unknown) 67 | 63:82:9D:D1:B3:A2 (OtherDevice) 68 | 65:B8:8C:38:D5:77 (unknown) 69 | 65:B8:8C:38:D5:77 (MyDevice) 70 | 65:B8:8C:38:D5:77 (unknown) 71 | """ 72 | for length in range(0, len(test_data)): 73 | result = GatttoolBackend._parse_scan_output(test_data[0:length]) 74 | self.assertEqual(len(result) > 0, length > 66) 75 | -------------------------------------------------------------------------------- /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, Optional 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 | pytype seems to have problems with this decorator around __init__, this 12 | leads to false positives in analysis results. 13 | """ 14 | try: 15 | # only do the wrapping if pygatt is installed. 16 | # otherwise it's pointless anyway 17 | from pygatt.backends.bgapi.exceptions import BGAPIError 18 | from pygatt.exceptions import NotConnectedError 19 | except ImportError: 20 | return func 21 | 22 | def _func_wrapper(*args, **kwargs): 23 | try: 24 | return func(*args, **kwargs) 25 | except BGAPIError as exception: 26 | raise BluetoothBackendException() from exception 27 | except NotConnectedError as exception: 28 | raise BluetoothBackendException() from exception 29 | 30 | return _func_wrapper 31 | 32 | 33 | class PygattBackend(AbstractBackend): 34 | """Bluetooth backend for Blue Giga based bluetooth devices.""" 35 | 36 | @wrap_exception 37 | def __init__(self, adapter: Optional[str] = None, address_type: str = "public"): 38 | """Create a new instance. 39 | Note: the parameter "adapter" is ignored, pygatt detects the right USB port automagically. 40 | """ 41 | super(PygattBackend, self).__init__(adapter, address_type) 42 | self.check_backend() 43 | 44 | import pygatt 45 | 46 | self._adapter = pygatt.BGAPIBackend() # type: pygatt.BGAPIBackend 47 | self._adapter.start() 48 | self._device = None 49 | 50 | def __del__(self): 51 | if self._adapter is not None: # pytype: disable=attribute-error 52 | self._adapter.stop() # pytype: disable=attribute-error 53 | 54 | @staticmethod 55 | def supports_scanning() -> bool: 56 | return False 57 | 58 | @wrap_exception 59 | def connect(self, mac: str): 60 | """Connect to a device.""" 61 | import pygatt 62 | 63 | address_type = pygatt.BLEAddressType.public 64 | if self.address_type == "random": 65 | address_type = pygatt.BLEAddressType.random 66 | self._device = self._adapter.connect(mac, address_type=address_type) 67 | 68 | def is_connected(self) -> bool: 69 | """Check if connected to a device.""" 70 | return self._device is not None # pytype: disable=attribute-error 71 | 72 | @wrap_exception 73 | def disconnect(self): 74 | """Disconnect from a device.""" 75 | if self.is_connected(): 76 | self._device.disconnect() 77 | self._device = None 78 | 79 | @wrap_exception 80 | def read_handle(self, handle: int) -> bytes: 81 | """Read a handle from the device.""" 82 | if not self.is_connected(): 83 | raise BluetoothBackendException("Not connected to device!") 84 | return self._device.char_read_handle(handle) 85 | 86 | @wrap_exception 87 | def write_handle(self, handle: int, value: bytes): 88 | """Write a handle to the device.""" 89 | if not self.is_connected(): 90 | raise BluetoothBackendException("Not connected to device!") 91 | self._device.char_write_handle(handle, value, True) 92 | return True 93 | 94 | @staticmethod 95 | def check_backend() -> bool: 96 | """Check if the backend is available.""" 97 | try: 98 | import pygatt # noqa: F401 # pylint: disable=unused-import 99 | 100 | return True 101 | except ImportError: 102 | return False 103 | -------------------------------------------------------------------------------- /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( 33 | "Call to %s failed, try %d of %d", func, error_count, RETRY_LIMIT 34 | ) 35 | raise BluetoothBackendException() from last_error 36 | 37 | return _func_wrapper 38 | 39 | 40 | class BluepyBackend(AbstractBackend): 41 | """Backend for Miflora using the bluepy library.""" 42 | 43 | def __init__(self, adapter: str = "hci0", address_type: str = "public"): 44 | """Create new instance of the backend.""" 45 | super(BluepyBackend, self).__init__(adapter, address_type) 46 | self._peripheral = None 47 | 48 | @wrap_exception 49 | def connect(self, mac: str): 50 | """Connect to a device.""" 51 | from bluepy.btle import Peripheral 52 | 53 | match_result = re.search(r"hci([\d]+)", self.adapter) 54 | if match_result is None: 55 | raise BluetoothBackendException( 56 | 'Invalid pattern "{}" for BLuetooth adpater. ' 57 | 'Expetected something like "hci0".'.format(self.adapter) 58 | ) 59 | iface = int(match_result.group(1)) 60 | self._peripheral = Peripheral(mac, iface=iface, addrType=self.address_type) 61 | 62 | @wrap_exception 63 | def disconnect(self): 64 | """Disconnect from a device if connected.""" 65 | if self._peripheral is None: 66 | return 67 | 68 | self._peripheral.disconnect() 69 | self._peripheral = None 70 | 71 | @wrap_exception 72 | def read_handle(self, handle: int) -> bytes: 73 | """Read a handle from the device. 74 | 75 | You must be connected to do this. 76 | """ 77 | if self._peripheral is None: 78 | raise BluetoothBackendException("not connected to backend") 79 | return self._peripheral.readCharacteristic(handle) 80 | 81 | @wrap_exception 82 | def write_handle(self, handle: int, value: bytes): 83 | """Write a handle from the device. 84 | 85 | You must be connected to do this. 86 | """ 87 | if self._peripheral is None: 88 | raise BluetoothBackendException("not connected to backend") 89 | return self._peripheral.writeCharacteristic(handle, value, True) 90 | 91 | @wrap_exception 92 | def wait_for_notification(self, handle: int, delegate, notification_timeout: float): 93 | if self._peripheral is None: 94 | raise BluetoothBackendException("not connected to backend") 95 | self.write_handle(handle, self._DATA_MODE_LISTEN) 96 | self._peripheral.withDelegate(delegate) 97 | return self._peripheral.waitForNotifications(notification_timeout) 98 | 99 | @staticmethod 100 | def supports_scanning() -> bool: 101 | return True 102 | 103 | @staticmethod 104 | def check_backend() -> bool: 105 | """Check if the backend is available.""" 106 | try: 107 | import bluepy.btle # noqa: F401 #pylint: disable=unused-import 108 | 109 | return True 110 | except ImportError as importerror: 111 | _LOGGER.error("bluepy not found: %s", str(importerror)) 112 | return False 113 | 114 | @staticmethod 115 | @wrap_exception 116 | def scan_for_devices(timeout: float, adapter="hci0") -> List[Tuple[str, str]]: 117 | """Scan for bluetooth low energy devices. 118 | 119 | Note this must be run as root!""" 120 | from bluepy.btle import Scanner 121 | 122 | match_result = re.search(r"hci([\d]+)", adapter) 123 | if match_result is None: 124 | raise BluetoothBackendException( 125 | 'Invalid pattern "{}" for BLuetooth adpater. ' 126 | 'Expetected something like "hci0".'.format(adapter) 127 | ) 128 | iface = int(match_result.group(1)) 129 | 130 | scanner = Scanner(iface=iface) 131 | result = [] 132 | for device in scanner.scan(timeout): 133 | result.append((device.addr, device.getValueText(9))) 134 | return result 135 | -------------------------------------------------------------------------------- /btlewrap/base.py: -------------------------------------------------------------------------------- 1 | """Bluetooth Backends available for miflora and other btle sensors.""" 2 | from threading import Lock 3 | from typing import List, Tuple, Optional 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__( 13 | self, 14 | backend: type, 15 | *, 16 | adapter: str = "hci0", 17 | address_type: str = "public", 18 | **kwargs 19 | ): 20 | self._backend = backend(adapter=adapter, address_type=address_type, **kwargs) 21 | self._backend.check_backend() 22 | 23 | def __del__(self): 24 | if self.is_connected(): 25 | self._backend.disconnect() 26 | 27 | def connect(self, mac) -> "_BackendConnection": 28 | """Connect to the sensor.""" 29 | return _BackendConnection(self._backend, mac) 30 | 31 | @staticmethod 32 | def is_connected() -> bool: 33 | """Check if we are connected to the sensor.""" 34 | return _BackendConnection.is_connected() 35 | 36 | 37 | class _BackendConnection: # pylint: disable=too-few-public-methods 38 | """Context Manager for a bluetooth connection. 39 | 40 | This creates the context for the connection and manages locking. 41 | """ 42 | 43 | _lock = Lock() 44 | 45 | def __init__(self, backend: "AbstractBackend", mac: str): 46 | self._backend = backend # type: AbstractBackend 47 | self._mac = mac # type: str 48 | self._has_lock = False 49 | 50 | def __enter__(self) -> "AbstractBackend": 51 | self._lock.acquire() 52 | self._has_lock = True 53 | try: 54 | self._backend.connect(self._mac) 55 | # release lock on any exceptions otherwise it will never be unlocked 56 | except: # noqa: E722 57 | self._cleanup() 58 | raise 59 | return self._backend 60 | 61 | def __exit__(self, exc_type, exc_val, exc_tb): 62 | self._cleanup() 63 | 64 | def __del__(self): 65 | self._cleanup() 66 | 67 | def _cleanup(self): 68 | if self._has_lock: 69 | self._backend.disconnect() 70 | self._lock.release() 71 | self._has_lock = False 72 | 73 | @staticmethod 74 | def is_connected() -> bool: 75 | """Check if the BackendConnection is connected.""" 76 | return _BackendConnection._lock.locked() # pylint: disable=no-member 77 | 78 | 79 | class BluetoothBackendException(Exception): 80 | """Exception thrown by the different backends. 81 | 82 | This is a wrapper for other exception specific to each library.""" 83 | 84 | 85 | class AbstractBackend: 86 | """Abstract base class for talking to Bluetooth LE devices. 87 | 88 | This class will be overridden by the different backends used by miflora and other btle sensors. 89 | """ 90 | 91 | _DATA_MODE_LISTEN = bytes([0x01, 0x00]) 92 | 93 | def __init__(self, adapter: str, address_type: str, **kwargs): 94 | self.adapter = adapter 95 | self.address_type = address_type 96 | self.kwargs = kwargs 97 | 98 | def connect(self, mac: str): 99 | """connect to a device with the given @mac. 100 | 101 | only required by some backends""" 102 | 103 | def disconnect(self): 104 | """disconnect from a device. 105 | 106 | Only required by some backends""" 107 | 108 | def write_handle(self, handle: int, value: bytes): 109 | """Write a value to a handle. 110 | 111 | You must be connected to a device first.""" 112 | raise NotImplementedError 113 | 114 | def wait_for_notification(self, handle: int, delegate, notification_timeout: float): 115 | """registers as a listener and calls the delegate's handleNotification 116 | for each notification received 117 | @param handle - the handle to use to register for notifications 118 | @param delegate - the delegate object's handleNotification is called for every notification received 119 | @param notification_timeout - wait this amount of seconds for notifications 120 | 121 | """ 122 | raise NotImplementedError 123 | 124 | def read_handle(self, handle: int) -> bytes: 125 | """Read a handle from the sensor. 126 | 127 | You must be connected to a device first.""" 128 | raise NotImplementedError 129 | 130 | @staticmethod 131 | def check_backend() -> bool: 132 | """Check if the backend is available on the current system. 133 | 134 | Returns True if the backend is available and False otherwise 135 | """ 136 | raise NotImplementedError 137 | 138 | @staticmethod 139 | def scan_for_devices( 140 | timeout: int, adapter: Optional[str] = None 141 | ) -> List[Tuple[str, str]]: 142 | """Scan for additional devices. 143 | 144 | Returns a list of all the mac addresses of Xiaomi Mi Flower sensor that could be found. 145 | """ 146 | raise NotImplementedError 147 | 148 | @staticmethod 149 | def supports_scanning() -> bool: 150 | """Check if this backend supports scanning for adapters.""" 151 | raise NotImplementedError 152 | -------------------------------------------------------------------------------- /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 | 17 | # access to protected members is fine in testing 18 | # pylint: disable = protected-access 19 | 20 | # arguments of mock.patch are not always used 21 | # tests have more methos that usual 22 | # pylint: disable = unused-argument, too-many-public-methods 23 | 24 | handle_notification_called = False 25 | 26 | @mock.patch("btlewrap.gatttool.Popen") 27 | def test_read_handle_ok(self, popen_mock): 28 | """Test reading handle successfully.""" 29 | gattoutput = bytes([0x00, 0x11, 0xAA, 0xFF]) 30 | _configure_popenmock(popen_mock, "Characteristic value/descriptor: 00 11 AA FF") 31 | backend = GatttoolBackend() 32 | backend.connect(TEST_MAC) 33 | result = backend.read_handle(0xFF) 34 | self.assertEqual(gattoutput, result) 35 | 36 | def test_run_connect_disconnect(self): 37 | """Just run connect and disconnect""" 38 | backend = GatttoolBackend() 39 | backend.connect(TEST_MAC) 40 | self.assertEqual(TEST_MAC, backend._mac) 41 | backend.disconnect() 42 | self.assertEqual(None, backend._mac) 43 | 44 | @mock.patch("btlewrap.gatttool.Popen") 45 | @mock.patch("time.sleep", return_value=None) 46 | def test_read_handle_empty_output(self, _, popen_mock): 47 | """Test reading handle where no result is returned.""" 48 | _configure_popenmock(popen_mock, "") 49 | backend = GatttoolBackend() 50 | backend.connect(TEST_MAC) 51 | with self.assertRaises(BluetoothBackendException): 52 | backend.read_handle(0xFF) 53 | 54 | @mock.patch("btlewrap.gatttool.Popen") 55 | def test_read_handle_wrong_handle(self, popen_mock): 56 | """Test reading invalid handle.""" 57 | _configure_popenmock( 58 | popen_mock, "Characteristic value/descriptor read failed: Invalid handle" 59 | ) 60 | backend = GatttoolBackend() 61 | backend.connect(TEST_MAC) 62 | with self.assertRaises(BluetoothBackendException): 63 | backend.read_handle(0xFF) 64 | 65 | def test_read_not_connected(self): 66 | """Test reading data when not connected.""" 67 | backend = GatttoolBackend() 68 | with self.assertRaises(BluetoothBackendException): 69 | backend.read_handle(0xFF) 70 | 71 | @mock.patch("os.killpg") 72 | @mock.patch("btlewrap.gatttool.Popen") 73 | @mock.patch("time.sleep", return_value=None) 74 | def test_read_handle_timeout(self, time_mock, popen_mock, os_mock): 75 | """Test notification when timeout""" 76 | _configure_popenmock_timeout(popen_mock, "Characteristic") 77 | backend = GatttoolBackend() 78 | backend.connect(TEST_MAC) 79 | with self.assertRaises(BluetoothBackendException): 80 | backend.read_handle(0xFF) 81 | 82 | def test_write_not_connected(self): 83 | """Test writing data when not connected.""" 84 | backend = GatttoolBackend() 85 | with self.assertRaises(BluetoothBackendException): 86 | backend.write_handle(0xFF, [0x00]) 87 | 88 | @mock.patch("btlewrap.gatttool.Popen") 89 | @mock.patch("time.sleep", return_value=None) 90 | def test_write_handle_ok(self, time_mock, popen_mock): 91 | """Test writing to a handle successfully.""" 92 | _configure_popenmock( 93 | popen_mock, "Characteristic value was written successfully" 94 | ) 95 | backend = GatttoolBackend() 96 | backend.connect(TEST_MAC) 97 | self.assertTrue(backend.write_handle(0xFF, b"\x00\x10\xFF")) 98 | 99 | @mock.patch("btlewrap.gatttool.Popen") 100 | @mock.patch("time.sleep", return_value=None) 101 | def test_write_handle_wrong_handle(self, time_mock, popen_mock): 102 | """Test writing to a non-writable handle.""" 103 | _configure_popenmock( 104 | popen_mock, 105 | "Characteristic Write Request failed: Attribute can't be written", 106 | ) 107 | backend = GatttoolBackend() 108 | backend.connect(TEST_MAC) 109 | with self.assertRaises(BluetoothBackendException): 110 | backend.write_handle(0xFF, b"\x00\x10\xFF") 111 | 112 | @mock.patch("btlewrap.gatttool.Popen") 113 | @mock.patch("time.sleep", return_value=None) 114 | def test_write_handle_no_answer(self, time_mock, popen_mock): 115 | """Test writing to a handle when no result is returned.""" 116 | _configure_popenmock(popen_mock, "") 117 | backend = GatttoolBackend() 118 | backend.connect(TEST_MAC) 119 | with self.assertRaises(BluetoothBackendException): 120 | backend.write_handle(0xFF, b"\x00\x10\xFF") 121 | 122 | @mock.patch("os.killpg") 123 | @mock.patch("btlewrap.gatttool.Popen") 124 | @mock.patch("time.sleep", return_value=None) 125 | def test_write_handle_timeout(self, time_mock, popen_mock, os_mock): 126 | """Test notification when timeout""" 127 | _configure_popenmock_timeout(popen_mock, "Characteristic") 128 | backend = GatttoolBackend() 129 | backend.connect(TEST_MAC) 130 | with self.assertRaises(BluetoothBackendException): 131 | backend.write_handle(0xFF, b"\x00\x10\xFF") 132 | 133 | def test_notification_not_connected(self): 134 | """Test writing data when not connected.""" 135 | backend = GatttoolBackend() 136 | with self.assertRaises(BluetoothBackendException): 137 | backend.wait_for_notification(0xFF, self, 10) 138 | 139 | @mock.patch("btlewrap.gatttool.Popen") 140 | @mock.patch("time.sleep", return_value=None) 141 | def test_wait_for_notification(self, time_mock, popen_mock): 142 | """Test notification successfully.""" 143 | _configure_popenmock( 144 | popen_mock, 145 | ( 146 | "Characteristic value was written successfully\n" 147 | "Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00\n" 148 | "Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00\n" 149 | "Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00" 150 | ), 151 | ) 152 | backend = GatttoolBackend() 153 | backend.connect(TEST_MAC) 154 | self.handle_notification_called = False 155 | self.assertTrue(backend.wait_for_notification(0xFF, self, 10)) 156 | self.assertTrue(self.handle_notification_called) 157 | 158 | def handleNotification( 159 | self, handle, raw_data 160 | ): # pylint: disable=unused-argument,invalid-name,no-self-use 161 | """gets called by the backend when using wait_for_notification""" 162 | if raw_data is None: 163 | raise Exception("no data given") 164 | self.assertTrue(len(raw_data) == 14) 165 | self.handle_notification_called = True 166 | 167 | @mock.patch("btlewrap.gatttool.Popen") 168 | @mock.patch("time.sleep", return_value=None) 169 | def test_notification_wrong_handle(self, time_mock, popen_mock): 170 | """Test notification when wrong handle""" 171 | _configure_popenmock( 172 | popen_mock, 173 | "Characteristic Write Request failed: Attribute can't be written", 174 | ) 175 | backend = GatttoolBackend() 176 | backend.connect(TEST_MAC) 177 | with self.assertRaises(BluetoothBackendException): 178 | backend.wait_for_notification(0xFF, self, 10) 179 | 180 | @mock.patch("btlewrap.gatttool.Popen") 181 | @mock.patch("time.sleep", return_value=None) 182 | def test_notification_no_answer(self, time_mock, popen_mock): 183 | """Test notification when no result is returned.""" 184 | _configure_popenmock(popen_mock, "") 185 | backend = GatttoolBackend() 186 | backend.connect(TEST_MAC) 187 | with self.assertRaises(BluetoothBackendException): 188 | backend.wait_for_notification(0xFF, self, 10) 189 | 190 | @mock.patch("os.killpg") 191 | @mock.patch("btlewrap.gatttool.Popen") 192 | @mock.patch("time.sleep", return_value=None) 193 | def test_notification_timeout(self, time_mock, popen_mock, os_mock): 194 | """Test notification when timeout""" 195 | _configure_popenmock_timeout( 196 | popen_mock, 197 | ( 198 | "Characteristic value was written successfully\n" 199 | "Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00\n" 200 | "Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00\n" 201 | "Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00" 202 | ), 203 | ) 204 | backend = GatttoolBackend() 205 | backend.connect(TEST_MAC) 206 | self.handle_notification_called = False 207 | self.assertTrue(backend.wait_for_notification(0xFF, self, 10)) 208 | self.assertTrue(self.handle_notification_called) 209 | 210 | @mock.patch("btlewrap.gatttool.run", return_value=None) 211 | def test_check_backend_ok(self, call_mock): 212 | """Test check_backend successfully.""" 213 | self.assertTrue(GatttoolBackend().check_backend()) 214 | 215 | @mock.patch("btlewrap.gatttool.run", **{"side_effect": IOError()}) 216 | def test_check_backend_fail(self, call_mock): 217 | """Test check_backend with IOError being risen.""" 218 | self.assertFalse(GatttoolBackend().check_backend()) 219 | 220 | def test_notification_payload_ok(self): 221 | """testing data processing""" 222 | 223 | notification_response = ( 224 | "Characteristic value was written successfully\n" 225 | "Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00\n" 226 | "Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00\n" 227 | "Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00" 228 | ) 229 | data = GatttoolBackend().extract_notification_payload(notification_response) 230 | self.assertTrue(len(data) == 3) 231 | self.assertTrue(data[0] == "54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00") 232 | self.assertTrue(data[2] == "54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00") 233 | 234 | def test_supports_scanning(self): 235 | """Check if scanning is set correctly.""" 236 | backend = GatttoolBackend() 237 | self.assertTrue(backend.supports_scanning()) 238 | 239 | 240 | def _configure_popenmock(popen_mock, output_string): 241 | """Helper function to create a mock for Popen.""" 242 | match_result = mock.Mock() 243 | match_result.communicate.return_value = [ 244 | bytes(output_string, encoding="UTF-8"), 245 | bytes("random text", encoding="UTF-8"), 246 | ] 247 | popen_mock.return_value.__enter__.return_value = match_result 248 | 249 | 250 | def _configure_popenmock_timeout(popen_mock, output_string): 251 | """Helper function to create a mock for Popen.""" 252 | match_result = mock.Mock() 253 | match_result.communicate = POpenHelper(output_string).communicate_timeout 254 | popen_mock.return_value.__enter__.return_value = match_result 255 | 256 | 257 | class POpenHelper: 258 | """Helper class to configure Popen mock behavior for timeout""" 259 | 260 | partial_response = "" 261 | 262 | def __init__(self, output_string=None): 263 | self.set_partial_response(output_string) 264 | 265 | def set_partial_response(self, response): 266 | """set response on timeout""" 267 | self.partial_response = response 268 | 269 | def communicate_timeout( 270 | self, timeout=None 271 | ): # pylint: disable=no-self-use,unused-argument 272 | """pass this method as a replacement to themocked Popen.communicate method""" 273 | process = mock.Mock() 274 | process.pid = 0 275 | if timeout: 276 | raise TimeoutExpired(process, timeout) 277 | return [bytes(self.partial_response, "utf-8")] 278 | -------------------------------------------------------------------------------- /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, List, Tuple, Optional 13 | from subprocess import Popen, PIPE, TimeoutExpired, run 14 | import signal 15 | from btlewrap.base import AbstractBackend, BluetoothBackendException 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | def wrap_exception(func: Callable) -> Callable: 21 | """Wrap all IOErrors to BluetoothBackendException""" 22 | 23 | def _func_wrapper(*args, **kwargs): 24 | try: 25 | return func(*args, **kwargs) 26 | except IOError as exception: 27 | raise BluetoothBackendException() from exception 28 | 29 | return _func_wrapper 30 | 31 | 32 | class GatttoolBackend(AbstractBackend): 33 | """Backend using gatttool.""" 34 | 35 | # pylint: disable=subprocess-popen-preexec-fn 36 | 37 | def __init__( 38 | self, 39 | adapter: str = "hci0", 40 | *, 41 | retries: int = 3, 42 | timeout: float = 20, 43 | address_type: str = "public", 44 | ): 45 | super(GatttoolBackend, self).__init__(adapter, address_type) 46 | self.adapter = adapter 47 | self.retries = retries 48 | self.timeout = timeout 49 | self.address_type = address_type 50 | self._mac = None 51 | 52 | @staticmethod 53 | def supports_scanning() -> bool: 54 | return True 55 | 56 | def connect(self, mac: str): 57 | """Connect to sensor. 58 | 59 | Connection handling is not required when using gatttool, but we still need the mac 60 | """ 61 | self._mac = mac 62 | 63 | def disconnect(self): 64 | """Disconnect from sensor. 65 | 66 | Connection handling is not required when using gatttool. 67 | """ 68 | self._mac = None 69 | 70 | def is_connected(self) -> bool: 71 | """Check if we are connected to the backend.""" 72 | return self._mac is not None 73 | 74 | @wrap_exception 75 | def write_handle(self, handle: int, value: bytes): 76 | # noqa: C901 77 | # pylint: disable=arguments-differ 78 | 79 | """Read from a BLE address. 80 | 81 | @param: mac - MAC address in format XX:XX:XX:XX:XX:XX 82 | @param: handle - BLE characteristics handle in format 0xXX 83 | @param: value - value to write to the given handle 84 | """ 85 | 86 | if not self.is_connected(): 87 | raise BluetoothBackendException("Not connected to any device.") 88 | 89 | attempt = 0 90 | delay = 10 91 | _LOGGER.debug("Enter write_ble (%s)", current_thread()) 92 | 93 | while attempt <= self.retries: 94 | cmd = "gatttool --device={} --addr-type={} --char-write-req -a {} -n {} --adapter={}".format( 95 | self._mac, 96 | self.address_type, 97 | self.byte_to_handle(handle), 98 | self.bytes_to_string(value), 99 | self.adapter, 100 | ) 101 | _LOGGER.debug( 102 | "Running gatttool with a timeout of %d: %s", self.timeout, cmd 103 | ) 104 | 105 | with Popen( 106 | cmd, shell=True, stdout=PIPE, stderr=PIPE, preexec_fn=os.setsid 107 | ) as process: 108 | try: 109 | result = process.communicate(timeout=self.timeout)[0] 110 | _LOGGER.debug("Finished gatttool") 111 | except TimeoutExpired: 112 | # send signal to the process group 113 | os.killpg(process.pid, signal.SIGINT) 114 | result = process.communicate()[0] 115 | _LOGGER.debug("Killed hanging gatttool") 116 | 117 | result = result.decode("utf-8").strip(" \n\t") 118 | if "Write Request failed" in result: 119 | raise BluetoothBackendException( 120 | "Error writing handle to sensor: {}".format(result) 121 | ) 122 | _LOGGER.debug("Got %s from gatttool", result) 123 | # Parse the output 124 | if "successfully" in result: 125 | _LOGGER.debug("Exit write_ble with result (%s)", current_thread()) 126 | return True 127 | 128 | attempt += 1 129 | _LOGGER.debug("Waiting for %s seconds before retrying", delay) 130 | if attempt < self.retries: 131 | time.sleep(delay) 132 | delay *= 2 133 | 134 | raise BluetoothBackendException( 135 | "Exit write_ble, no data ({})".format(current_thread()) 136 | ) 137 | 138 | @wrap_exception 139 | def wait_for_notification(self, handle: int, delegate, notification_timeout: float): 140 | """Listen for characteristics changes from a BLE address. 141 | 142 | @param: mac - MAC address in format XX:XX:XX:XX:XX:XX 143 | @param: handle - BLE characteristics handle in format 0xXX 144 | a value of 0x0100 is written to register for listening 145 | @param: delegate - gatttool receives the 146 | --listen argument and the delegate object's handleNotification is 147 | called for every returned row 148 | @param: notification_timeout 149 | """ 150 | 151 | if not self.is_connected(): 152 | raise BluetoothBackendException("Not connected to any device.") 153 | 154 | attempt = 0 155 | delay = 10 156 | _LOGGER.debug("Enter write_ble (%s)", current_thread()) 157 | 158 | while attempt <= self.retries: 159 | cmd = "gatttool --device={} --addr-type={} --char-write-req -a {} -n {} --adapter={} --listen".format( 160 | self._mac, 161 | self.address_type, 162 | self.byte_to_handle(handle), 163 | self.bytes_to_string(self._DATA_MODE_LISTEN), 164 | self.adapter, 165 | ) 166 | _LOGGER.debug( 167 | "Running gatttool with a timeout of %d: %s", notification_timeout, cmd 168 | ) 169 | 170 | with Popen( 171 | cmd, shell=True, stdout=PIPE, stderr=PIPE, preexec_fn=os.setsid 172 | ) as process: 173 | try: 174 | result = process.communicate(timeout=notification_timeout)[0] 175 | _LOGGER.debug("Finished gatttool") 176 | except TimeoutExpired: 177 | # send signal to the process group, because listening always hangs 178 | os.killpg(process.pid, signal.SIGINT) 179 | result = process.communicate()[0] 180 | _LOGGER.debug("Listening stopped forcefully after timeout.") 181 | 182 | result = result.decode("utf-8").strip(" \n\t") 183 | if "Write Request failed" in result: 184 | raise BluetoothBackendException( 185 | "Error writing handle to sensor: {}".format(result) 186 | ) 187 | _LOGGER.debug("Got %s from gatttool", result) 188 | # Parse the output to determine success 189 | if "successfully" in result: 190 | _LOGGER.debug("Exit write_ble with result (%s)", current_thread()) 191 | # extract useful data. 192 | for element in self.extract_notification_payload(result): 193 | delegate.handleNotification( 194 | handle, bytes([int(x, 16) for x in element.split()]) 195 | ) 196 | return True 197 | 198 | attempt += 1 199 | _LOGGER.debug("Waiting for %s seconds before retrying", delay) 200 | if attempt < self.retries: 201 | time.sleep(delay) 202 | delay *= 2 203 | 204 | raise BluetoothBackendException( 205 | "Exit write_ble, no data ({})".format(current_thread()) 206 | ) 207 | 208 | @staticmethod 209 | def extract_notification_payload(process_output): 210 | """ 211 | Processes the raw output from Gatttool stripping the first line and the 212 | 'Notification handle = 0x000e value: ' from each line 213 | @param: process_output - the raw output from a listen commad of GattTool 214 | which may look like this: 215 | Characteristic value was written successfully 216 | Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 30 00 217 | Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 32 00 218 | Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 31 00 219 | Notification handle = 0x000e value: 54 3d 32 37 2e 32 20 48 3d 32 37 2e 33 00 220 | Notification handle = 0x000e value: 54 3d 32 37 2e 33 20 48 3d 32 37 2e 31 00 221 | Notification handle = 0x000e value: 54 3d 32 37 2e 31 20 48 3d 32 37 2e 34 00 222 | 223 | 224 | This method strips the fist line and strips the 'Notification handle = 0x000e value: ' from each line 225 | @returns a processed string only containing the values. 226 | """ 227 | data = [] 228 | for element in process_output.splitlines()[1:]: 229 | parts = element.split(": ") 230 | if len(parts) == 2: 231 | data.append(parts[1]) 232 | return data 233 | 234 | @wrap_exception 235 | def read_handle(self, handle: int) -> bytes: 236 | """Read from a BLE address. 237 | 238 | @param: mac - MAC address in format XX:XX:XX:XX:XX:XX 239 | @param: handle - BLE characteristics handle in format 0xXX 240 | @param: timeout - timeout in seconds 241 | """ 242 | 243 | if not self.is_connected(): 244 | raise BluetoothBackendException("Not connected to any device.") 245 | 246 | attempt = 0 247 | delay = 10 248 | _LOGGER.debug("Enter read_ble (%s)", current_thread()) 249 | 250 | while attempt <= self.retries: 251 | cmd = "gatttool --device={} --addr-type={} --char-read -a {} --adapter={}".format( 252 | self._mac, self.address_type, self.byte_to_handle(handle), self.adapter 253 | ) 254 | _LOGGER.debug( 255 | "Running gatttool with a timeout of %d: %s", self.timeout, cmd 256 | ) 257 | with Popen( 258 | cmd, shell=True, stdout=PIPE, stderr=PIPE, preexec_fn=os.setsid 259 | ) as process: 260 | try: 261 | result = process.communicate(timeout=self.timeout)[0] 262 | _LOGGER.debug("Finished gatttool") 263 | except TimeoutExpired: 264 | # send signal to the process group 265 | os.killpg(process.pid, signal.SIGINT) 266 | result = process.communicate()[0] 267 | _LOGGER.debug("Killed hanging gatttool") 268 | 269 | result = result.decode("utf-8").strip(" \n\t") 270 | _LOGGER.debug('Got "%s" from gatttool', result) 271 | # Parse the output 272 | if "read failed" in result: 273 | raise BluetoothBackendException( 274 | "Read error from gatttool: {}".format(result) 275 | ) 276 | 277 | res = re.search("( [0-9a-fA-F][0-9a-fA-F])+", result) 278 | if res: 279 | _LOGGER.debug("Exit read_ble with result (%s)", current_thread()) 280 | return bytes([int(x, 16) for x in res.group(0).split()]) 281 | 282 | attempt += 1 283 | _LOGGER.debug("Waiting for %s seconds before retrying", delay) 284 | if attempt < self.retries: 285 | time.sleep(delay) 286 | delay *= 2 287 | 288 | raise BluetoothBackendException( 289 | "Exit read_ble, no data ({})".format(current_thread()) 290 | ) 291 | 292 | @staticmethod 293 | def check_backend() -> bool: 294 | """Check if gatttool is available on the system.""" 295 | try: 296 | run(["gatttool", "-h"], stdout=PIPE, stderr=PIPE, check=True) 297 | return True 298 | except OSError as os_err: 299 | msg = "gatttool not found: {}".format(str(os_err)) 300 | _LOGGER.error(msg) 301 | return False 302 | 303 | @staticmethod 304 | def byte_to_handle(in_byte: int) -> str: 305 | """Convert a byte array to a handle string.""" 306 | return "0x" + "{:02x}".format(in_byte).upper() 307 | 308 | @staticmethod 309 | def bytes_to_string(raw_data: bytes, prefix: bool = False) -> str: 310 | """Convert a byte array to a hex string.""" 311 | prefix_string = "" 312 | if prefix: 313 | prefix_string = "0x" 314 | suffix = "".join([format(c, "02x") for c in raw_data]) 315 | return prefix_string + suffix.upper() 316 | 317 | @staticmethod 318 | def scan_for_devices( 319 | timeout: int = 10, adapter: Optional[str] = None 320 | ) -> List[Tuple[str, str]]: 321 | # call hcitool with a timeout, otherwise it will scan forever 322 | cmd = ["timeout", "-s", "SIGINT", f"{timeout}s", "hcitool"] 323 | if adapter is not None: 324 | cmd += ["-i", adapter] 325 | cmd += ["lescan"] 326 | # setting check=False as process is killed by timeout 327 | proc = run( 328 | cmd, stdout=PIPE, stderr=None, timeout=2 * timeout, text=True, check=False 329 | ) 330 | return GatttoolBackend._parse_scan_output(proc.stdout) 331 | 332 | @staticmethod 333 | def _parse_scan_output(scan_output: str) -> List[Tuple[str, str]]: 334 | # skip first line containing "LE Scan ..." 335 | devices = dict() 336 | device_regex = re.compile( 337 | r"(?P([\dA-Fa-f]{2}:){5}[\dA-Fa-f]{2})\W+\((?P[^\)]+)\)" 338 | ) 339 | # gatttool constant if device name is unknown 340 | name_unknown = "unknown" 341 | for line in scan_output.split("\n")[1:]: 342 | match = device_regex.search(line) 343 | if match is None or match.group("mac") is None: 344 | continue 345 | mac = match.group("mac") 346 | name = match.group("name") 347 | 348 | if mac not in devices or devices[mac] == name_unknown: 349 | devices[mac] = name 350 | 351 | return list(devices.items()) 352 | --------------------------------------------------------------------------------