From 9b6a51a03ee496b7d6849c1aa00c9a57a3a9b179 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Wed, 29 Oct 2025 12:54:59 +0100 Subject: [PATCH] Use local version of able --- libs/able/.gitignore | 16 + libs/able/CHANGELOG.rst | 103 +++++ libs/able/LICENSE | 22 ++ libs/able/MANIFEST.in | 4 + libs/able/able/__init__.py | 84 ++++ libs/able/able/adapter.py | 179 +++++++++ libs/able/able/advertising.py | 330 ++++++++++++++++ libs/able/able/android/__init__.py | 0 libs/able/able/android/dispatcher.py | 105 +++++ libs/able/able/android/jni.py | 151 +++++++ libs/able/able/dispatcher.py | 371 ++++++++++++++++++ libs/able/able/filters.py | 237 +++++++++++ libs/able/able/permissions.py | 53 +++ libs/able/able/queue.py | 90 +++++ libs/able/able/scan_settings.py | 20 + libs/able/able/src/org/able/BLE.java | 283 +++++++++++++ .../able/able/src/org/able/BLEAdvertiser.java | 61 +++ .../able/src/org/able/PythonBluetooth.java | 25 ++ .../org/able/PythonBluetoothAdvertiser.java | 13 + libs/able/able/structures.py | 81 ++++ libs/able/able/utils.py | 42 ++ libs/able/able/version.py | 5 + libs/able/docs/Makefile | 177 +++++++++ libs/able/docs/api.rst | 154 ++++++++ libs/able/docs/conf.py | 295 ++++++++++++++ libs/able/docs/example.rst | 192 +++++++++ libs/able/docs/index.rst | 3 + libs/able/examples/adapter_state_change.py | 27 ++ libs/able/examples/advertising_battery.py | 71 ++++ libs/able/examples/alert/buildozer.spec | 27 ++ libs/able/examples/alert/error_message.kv | 21 + libs/able/examples/alert/error_message.py | 39 ++ libs/able/examples/alert/main.py | 64 +++ libs/able/examples/mtu.py | 52 +++ .../examples/multi_devices/buildozer.spec | 27 ++ libs/able/examples/multi_devices/main.py | 96 +++++ .../examples/service_advertise/buildozer.spec | 27 ++ libs/able/examples/service_advertise/main.py | 56 +++ .../examples/service_advertise/service.py | 28 ++ .../able/examples/service_scan/buildozer.spec | 29 ++ libs/able/examples/service_scan/main.py | 48 +++ libs/able/examples/service_scan/service.py | 27 ++ libs/able/recipes/__init__.py | 0 libs/able/recipes/able_recipe/__init__.py | 34 ++ libs/able/recipes/able_recipe/setup.py | 9 + libs/able/setup.py | 135 +++++++ libs/able/testapps/bletest/.gitignore | 1 + libs/able/testapps/bletest/bletestapp.kv | 193 +++++++++ libs/able/testapps/bletest/buildozer.spec | 17 + libs/able/testapps/bletest/main.py | 245 ++++++++++++ libs/able/testapps/bletest/server.go | 95 +++++ libs/able/tests/__init__.py | 0 libs/able/tests/notebooks/.gitignore | 4 + libs/able/tests/notebooks/init.md | 43 ++ libs/able/tests/notebooks/run | 22 ++ libs/able/tests/notebooks/run_all_tests | 4 + libs/able/tests/notebooks/test_basic.expected | 26 ++ libs/able/tests/notebooks/test_basic.md | 74 ++++ .../notebooks/test_scan_filters.expected | 72 ++++ .../able/tests/notebooks/test_scan_filters.md | 222 +++++++++++ .../notebooks/test_scan_settings.expected | 27 ++ .../tests/notebooks/test_scan_settings.md | 105 +++++ libs/able/tests/test_adapter.py | 81 ++++ libs/able/tests/test_ble_queue.py | 34 ++ libs/able/tests/test_dispatcher.py | 47 +++ libs/able/tests/test_filters.py | 50 +++ libs/able/tests/test_setup.py | 30 ++ 67 files changed, 5305 insertions(+) create mode 100644 libs/able/.gitignore create mode 100644 libs/able/CHANGELOG.rst create mode 100644 libs/able/LICENSE create mode 100644 libs/able/MANIFEST.in create mode 100644 libs/able/able/__init__.py create mode 100644 libs/able/able/adapter.py create mode 100644 libs/able/able/advertising.py create mode 100644 libs/able/able/android/__init__.py create mode 100644 libs/able/able/android/dispatcher.py create mode 100644 libs/able/able/android/jni.py create mode 100644 libs/able/able/dispatcher.py create mode 100644 libs/able/able/filters.py create mode 100644 libs/able/able/permissions.py create mode 100644 libs/able/able/queue.py create mode 100644 libs/able/able/scan_settings.py create mode 100644 libs/able/able/src/org/able/BLE.java create mode 100644 libs/able/able/src/org/able/BLEAdvertiser.java create mode 100644 libs/able/able/src/org/able/PythonBluetooth.java create mode 100644 libs/able/able/src/org/able/PythonBluetoothAdvertiser.java create mode 100644 libs/able/able/structures.py create mode 100644 libs/able/able/utils.py create mode 100644 libs/able/able/version.py create mode 100644 libs/able/docs/Makefile create mode 100644 libs/able/docs/api.rst create mode 100644 libs/able/docs/conf.py create mode 100644 libs/able/docs/example.rst create mode 100644 libs/able/docs/index.rst create mode 100644 libs/able/examples/adapter_state_change.py create mode 100644 libs/able/examples/advertising_battery.py create mode 100644 libs/able/examples/alert/buildozer.spec create mode 100644 libs/able/examples/alert/error_message.kv create mode 100644 libs/able/examples/alert/error_message.py create mode 100644 libs/able/examples/alert/main.py create mode 100644 libs/able/examples/mtu.py create mode 100644 libs/able/examples/multi_devices/buildozer.spec create mode 100644 libs/able/examples/multi_devices/main.py create mode 100644 libs/able/examples/service_advertise/buildozer.spec create mode 100644 libs/able/examples/service_advertise/main.py create mode 100644 libs/able/examples/service_advertise/service.py create mode 100644 libs/able/examples/service_scan/buildozer.spec create mode 100644 libs/able/examples/service_scan/main.py create mode 100644 libs/able/examples/service_scan/service.py create mode 100644 libs/able/recipes/__init__.py create mode 100644 libs/able/recipes/able_recipe/__init__.py create mode 100644 libs/able/recipes/able_recipe/setup.py create mode 100644 libs/able/setup.py create mode 100644 libs/able/testapps/bletest/.gitignore create mode 100644 libs/able/testapps/bletest/bletestapp.kv create mode 100644 libs/able/testapps/bletest/buildozer.spec create mode 100644 libs/able/testapps/bletest/main.py create mode 100644 libs/able/testapps/bletest/server.go create mode 100644 libs/able/tests/__init__.py create mode 100644 libs/able/tests/notebooks/.gitignore create mode 100644 libs/able/tests/notebooks/init.md create mode 100755 libs/able/tests/notebooks/run create mode 100755 libs/able/tests/notebooks/run_all_tests create mode 100644 libs/able/tests/notebooks/test_basic.expected create mode 100644 libs/able/tests/notebooks/test_basic.md create mode 100644 libs/able/tests/notebooks/test_scan_filters.expected create mode 100644 libs/able/tests/notebooks/test_scan_filters.md create mode 100644 libs/able/tests/notebooks/test_scan_settings.expected create mode 100644 libs/able/tests/notebooks/test_scan_settings.md create mode 100644 libs/able/tests/test_adapter.py create mode 100644 libs/able/tests/test_ble_queue.py create mode 100644 libs/able/tests/test_dispatcher.py create mode 100644 libs/able/tests/test_filters.py create mode 100644 libs/able/tests/test_setup.py diff --git a/libs/able/.gitignore b/libs/able/.gitignore new file mode 100644 index 0000000..56629a1 --- /dev/null +++ b/libs/able/.gitignore @@ -0,0 +1,16 @@ +.buildozer/ +bin/ +docs/_build/ +*~ +*.swp +*.sublime-workspace +*.pyo +*.pyc +*.so + +build/ +dist/ +sdist/ +wheels/ +*.egg-info + diff --git a/libs/able/CHANGELOG.rst b/libs/able/CHANGELOG.rst new file mode 100644 index 0000000..f56f9ad --- /dev/null +++ b/libs/able/CHANGELOG.rst @@ -0,0 +1,103 @@ +Changelog +========= + +1.0.16 +------ + +* Added `autoconnect` parameter to connection methods + `#45 `_ + +1.0.15 +------ + +* Changing the wheel name to avoid installing a package from cache + `#40 `_ + +1.0.14 +------ + +* Added event handler for bluetooth adapter state change + `#39 `_ by `robgar2001 `_ +* Removal of deprecated `convert_path` from setup script + +1.0.13 +------ + +* Fixed build failure when pip isolated environment is used `#38 `_ + +1.0.12 +------ + +* Fixed crash on API level 31 (Android 12) `#37 `_ +* Added new optional `BluetoothDispatcher` parameter to specifiy required permissions: `runtime_permissions`. + Runtime permissions that are required by by default: + ACCESS_FINE_LOCATION, BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE +* Changed `able.require_bluetooth_enabled` behavior: first asks for runtime permissions + and if permissions are granted then offers to enable the adapter +* `require_runtime_permissions` decorator deprecated + +1.0.11 +------ + +* Improved logging of reconnection management + `#33 `_ by `robgar2001 `_ + +1.0.10 +------ + +* Fixed build failure after AAB support was added to python-for-android + +1.0.9 +----- + +* Switched from deprecated scanning method `BluetoothAdapter.startLeScan` to `BluetoothLeScanner.startScan` +* Added support for BLE scanning settings: `able.scan_settings` module +* Added support for BLE scanning filters: `able.filters` module + + +1.0.8 +----- + +* Added support to use `able` in Android services +* Added decorators: + + - `able.require_bluetooth_enabled`: to call `BluetoothDispatcher` method when bluetooth adapter becomes ready + - `able.require_runtime_permissions`: to call `BluetoothDispatcher` method when location runtime permission is granted + + +1.0.7 +----- + +* Added `able.advertising`: module to perform BLE advertise operations +* Added property to get and set Bluetoth adapter name + + +1.0.6 +----- + +* Fixed `TypeError` exception on `BluetoothDispatcher.enable_notifications` + + +1.0.5 +----- + +* Added `BluetoothDispatcher.bonded_devices` property: list of paired BLE devices + +1.0.4 +----- + +* Fixed sending string data with `write_characteristic` function + +1.0.3 +----- + +* Changed package version generation: + + - Version is set during the build, from the git tag + - Development (git master) version is always "0.0.0" +* Added ATT MTU changing method and callback +* Added MTU changing example +* Fixed: + + - set `BluetoothDispatcher.gatt` attribute in GATT connection handler, + to avoid possible `on_connection_state_change()` call before the `gatt` attribute is set diff --git a/libs/able/LICENSE b/libs/able/LICENSE new file mode 100644 index 0000000..53601b8 --- /dev/null +++ b/libs/able/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2017 b3b + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/libs/able/MANIFEST.in b/libs/able/MANIFEST.in new file mode 100644 index 0000000..571bea0 --- /dev/null +++ b/libs/able/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.rst +include CHANGELOG.rst +include able/src/org/able/*.java diff --git a/libs/able/able/__init__.py b/libs/able/able/__init__.py new file mode 100644 index 0000000..2ebad68 --- /dev/null +++ b/libs/able/able/__init__.py @@ -0,0 +1,84 @@ +from enum import IntEnum + +from able.structures import Advertisement, Services +from able.version import __version__ # noqa +from kivy.utils import platform + +__all__ = ( + "Advertisement", + "BluetoothDispatcher", + "Services", +) + +# constants +GATT_SUCCESS = 0 #: GATT operation completed successfully +STATE_CONNECTED = 2 #: The profile is in connected state +STATE_DISCONNECTED = 0 #: The profile is in disconnected state + + +class AdapterState(IntEnum): + """Bluetooth adapter state constants. + https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#STATE_OFF + """ + + OFF = 10 #: Adapter is off + TURNING_ON = 11 #: Adapter is turning on + ON = 12 #: Adapter is on + TURNING_OFF = 13 #: Adapter is turning off + + +class WriteType(IntEnum): + """GATT characteristic write types constants.""" + + DEFAULT = ( + 2 #: Write characteristic, requesting acknowledgement by the remote device + ) + NO_RESPONSE = ( + 1 #: Write characteristic without requiring a response by the remote device + ) + SIGNED = 4 #: Write characteristic including authentication signature + + +if platform == "android": + from able.android.dispatcher import BluetoothDispatcher +else: + + # mock android and PyJNIus modules usage + import sys + from unittest.mock import Mock + + sys.modules["android"] = Mock() + sys.modules["android.permissions"] = Mock() + jnius = Mock() + + class mocked_autoclass(Mock): + def __call__(self, *args, **kwargs): + mock = Mock() + mock.__repr__ = lambda s: f"jnius.autoclass('{args[0]}')" + mock.SDK_INT = 255 + return mock + + jnius.autoclass = mocked_autoclass() + sys.modules["jnius"] = jnius + + from able.dispatcher import BluetoothDispatcherBase + + class BluetoothDispatcher(BluetoothDispatcherBase): + """Bluetooth Low Energy interface + + :param queue_timeout: BLE operations queue timeout + :param enable_ble_code: request code to identify activity that alows + user to turn on Bluetooth adapter + :param runtime_permissions: overridden list of + :py:mod:`permissions ` + to be requested on runtime. + """ + + +from able.adapter import require_bluetooth_enabled +from able.permissions import Permission + + +def require_runtime_permissions(method): + """Deprecated decorator, left for backwards compatibility.""" + return method diff --git a/libs/able/able/adapter.py b/libs/able/able/adapter.py new file mode 100644 index 0000000..ae5f378 --- /dev/null +++ b/libs/able/able/adapter.py @@ -0,0 +1,179 @@ +from dataclasses import dataclass, field +from functools import partial, wraps +from typing import Optional + +from android import activity +from android.permissions import ( + check_permission, + request_permissions, +) +from jnius import autoclass +from kivy.logger import Logger + + +Activity = autoclass("android.app.Activity") + + +def require_bluetooth_enabled(method): + """Decorator to call `BluetoothDispatcher` method + when runtime permissions are granted + and Bluetooth adapter becomes ready. + + Decorator launches system activities that allows the user + to grant runtime permissions and turn on Bluetooth, + if Bluetooth is not enabled. + """ + + @wraps(method) + def wrapper(self, *args, **kwargs): + manager = AdapterManager.get_attached_manager(self) + if manager: + return manager.execute(partial(method, self, *args, **kwargs)) + return None + + return wrapper + + +def set_adapter_failure_rollback(handler): + """Decorator to launch handler + if permissions are not granted or adapter is not enabled.""" + + def inner(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + manager = AdapterManager.get_attached_manager(self) + if manager: + manager.rollback_handlers.append(partial(handler, self)) + return func(self, *args, **kwargs) + return None + + return wrapper + + return inner + + +@dataclass +class AdapterManager: + """ + Class for managing the execution of operations + that require the BLE adapter. + Operations are deferred until runtime permissions are granted + and the BLE adapter is enabled. + """ + + ble: "org.able.BLE" + enable_ble_code: str + runtime_permissions: list + operations: list = field(default_factory=list) + rollback_handlers: list = field(default_factory=list) + is_permissions_granted: bool = False + is_permissions_requested: bool = False + is_adapter_requested: bool = False + + @property + def adapter(self) -> Optional["android.bluetooth.BluetoothAdapter"]: + if self.has_permissions: + adapter = self.ble.mBluetoothAdapter + if adapter and adapter.isEnabled(): + return adapter + return None + + @property + def has_permissions(self): + if not self.is_permissions_granted: + self.is_permissions_granted = self.check_permissions() + return self.is_permissions_granted + + @property + def is_service_context(self): + return not activity._activity + + def __post_init__(self): + if self.is_service_context: + self.is_permissions_granted = True + else: + activity.bind(on_activity_result=self.on_activity_result) + + @classmethod + def get_attached_manager(cls, instance): + manager = getattr(instance, "_adapter_manager", None) + if not manager: + Logger.error("BLE adapter manager is not installed") + return manager + + def install(self, instance): + setattr(instance, "_adapter_manager", self) + + def check_permissions(self): + return all( + [check_permission(permission) for permission in self.runtime_permissions] + ) + + def request_permissions(self): + if self.is_permissions_requested: + return + self.is_permissions_requested = True + if not self.is_service_context: + Logger.debug("Request runtime permissions") + request_permissions( + self.runtime_permissions, + self.on_runtime_permissions, + ) + else: + Logger.error("Required permissions are not granted for service") + + def request_adapter(self): + if self.is_adapter_requested: + return + self.is_adapter_requested = True + self.ble.getAdapter(self.enable_ble_code) + + def rollback(self): + self._execute_operations(self.rollback_handlers) + + def execute(self, operation): + if self.adapter: + # execute immediately, if adapter is enabled + return operation() + self.operations.append(operation) + self.execute_operations() + + def execute_operations(self): + if self.has_permissions: + if self.adapter: + self._execute_operations(self.operations) + else: + self.request_adapter() + else: + self.request_permissions() + + def _execute_operations(self, operations): + self.operations = [] + self.rollback_handlers = [] + for operation in operations: + try: + operation() + except Exception as exc: + Logger.exception(exc) + + def on_runtime_permissions(self, permissions, grant_results): + granted = all(grant_results) + self.is_permissions_granted = granted + self.is_permissions_requested = False # allow future invocations + if granted: + Logger.debug("Required permissions are granted") + self.execute_operations() + else: + Logger.error("Required permissions are not granted") + self.rollback() + + def on_activity_result(self, requestCode, resultCode, intent): + if requestCode == self.enable_ble_code: + enabled = resultCode == Activity.RESULT_OK + self.is_adapter_requested = False # allow future invocations + if enabled: + Logger.debug("BLE adapter is enabled") + self.execute_operations() + else: + Logger.error("BLE adapter is not enabled") + self.rollback() diff --git a/libs/able/able/advertising.py b/libs/able/able/advertising.py new file mode 100644 index 0000000..1c61762 --- /dev/null +++ b/libs/able/able/advertising.py @@ -0,0 +1,330 @@ +"""BLE advertise operations.""" +from abc import abstractmethod +from dataclasses import dataclass +from enum import IntEnum +from typing import List, Optional, Union + +from jnius import JavaException, autoclass +from kivy.event import EventDispatcher + +from able.android.dispatcher import BluetoothDispatcher +from able.android.jni import PythonBluetoothAdvertiser +from able.utils import force_convertible_to_java_array + + +AdvertiseDataBuilder = autoclass('android.bluetooth.le.AdvertiseData$Builder') +AdvertisingSet = autoclass('android.bluetooth.le.AdvertisingSet') +AdvertisingSetParametersBuilder = autoclass('android.bluetooth.le.AdvertisingSetParameters$Builder') +AndroidAdvertiseData = autoclass('android.bluetooth.le.AdvertiseData') +BluetoothLeAdvertiser = autoclass('android.bluetooth.le.BluetoothLeAdvertiser') +ParcelUuid = autoclass('android.os.ParcelUuid') + +BLEAdvertiser = autoclass('org.able.BLEAdvertiser') + + +class Interval(IntEnum): + """Advertising interval constants. + https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters#INTERVAL_HIGH + """ + MIN = 160 #: Minimum value for advertising interval, around every 100ms + MEDIUM = 400 #: Advertise on medium frequency, around every 250ms + HIGH = 1600 #: Advertise on low frequency, around every 1000ms + MAX = 16777215 #: Maximum value for advertising interval + + +class TXPower(IntEnum): + """Advertising transmission (TX) power level constants. + https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters#TX_POWER_HIGH + """ + MIN = -127 #: Minimum value for TX power + ULTRA_LOW = -21 #: Advertise using the lowest TX power level + LOW = -15 #: Advertise using the low TX power level + MEDIUM = -7 #: Advertise using the medium TX power level + MAX = 1 #: Maximum value for TX power + + +class Status: + """Advertising operation status constants. + https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetCallback#constants + """ + SUCCESS = 0 + DATA_TOO_LARGE = 1 + TOO_MANY_ADVERTISERS = 2 + ALREADY_STARTED = 3 + INTERNAL_ERROR = 4 + FEATURE_UNSUPPORTED = 5 + + +@dataclass +class ADStructure: + + @abstractmethod + def add_payload(self, builder: AdvertiseDataBuilder): + pass + + +class DeviceName(ADStructure): + """Include device name (complete local name) in advertise packet.""" + + def add_payload(self, builder): + builder.setIncludeDeviceName(True) + + +class TXPowerLevel(ADStructure): + """Include transmission power level in the advertise packet.""" + + def add_payload(self, builder): + builder.setIncludeTxPowerLevel(True) + + +@dataclass +class ServiceUUID(ADStructure): + """Service UUID to advertise. + + :param uid: UUID to be advertised + """ + uid: str + + def add_payload(self, builder): + builder.addServiceUuid( + ParcelUuid.fromString(self.uid) + ) + + +@dataclass +class ServiceData(ADStructure): + """Service data to advertise. + + :param uid: UUID of the service the data is associated with + :param data: Service data + """ + + uid: str + data: Union[list, tuple, bytes, bytearray] + + def add_payload(self, builder): + builder.addServiceData( + ParcelUuid.fromString(self.uid), + force_convertible_to_java_array(self.data) + ) + + +@dataclass +class ManufacturerData(ADStructure): + """Manufacturer specific data to advertise. + + :param id: Manufacturer ID + :param data: Manufacturer specific data + """ + id: int + data: Union[list, tuple, bytes, bytearray] + + def add_payload(self, builder): + builder.addManufacturerData( + self.id, + force_convertible_to_java_array(self.data) + ) + + +class AdvertiseData: + """Builder for data payload to be advertised. + + :param payload: List of AD structures to include in advertisement + + >>> AdvertiseData(DeviceName(), ManufacturerData(10, b'specific data')) + [DeviceName(), ManufacturerData(id=10, data=b'specific data')] + """ + + def __init__(self, *payload: List[ADStructure]): + self.payload = payload + self.data = self.build() + + def __repr__(self): + sections = ", ".join(repr(ad) for ad in self.payload) + return f"[{sections}]" + + def build(self) -> AndroidAdvertiseData: + builder = AdvertiseDataBuilder() + for ad in self.payload: + ad.add_payload(builder) + return builder.build() + + +class Advertiser(EventDispatcher): + """Base class for BLE advertise operations. + + :param ble: BLE interface instance + :param data: Advertisement data to be broadcasted + :param scan_data: Scan response associated with the advertisement data + :param interval: Advertising interval + ``_ + :param tx_power: Transmission power level + ``_ + + >>> Advertiser( + ... ble=BluetoothDispatcher(), + ... data=AdvertiseData(DeviceName()), + ... scan_data=AdvertiseData(TXPowerLevel()), + ... interval=Interval.MIN, + ... tx_power=TXPower.MAX + ... ) #doctest: +ELLIPSIS + + """ + + __events__ = ( + 'on_advertising_started', + 'on_advertising_stopped', + 'on_advertising_enabled', + 'on_advertising_data_set', + 'on_scan_response_data_set', + 'on_advertising_parameters_updated', + 'on_advertising_set_changed', + ) + + def __init__( + self, + ble: BluetoothDispatcher, + data: AdvertiseData = None, + scan_data: AdvertiseData = None, + interval: int = Interval.HIGH, + tx_power: int = TXPower.MEDIUM, + ): + self._ble = ble + self._data = data + self._scan_data = scan_data + self._interval = interval + self._tx_power = tx_power + + self._events_interface = PythonBluetoothAdvertiser(self) + self._advertiser = BLEAdvertiser(self._events_interface) + self._callback_set = self._advertiser.mCallbackSet + self._advertising_set = None + + @property + def data(self): + """ + :setter: Update advertising data + :type: Optional[AdvertiseData] + """ + return self._data + + @data.setter + def data(self, value): + self._data = value + self._update_advertising_set() + + @property + def scan_data(self): + """ + :setter: Update the scan response + :type: Optional[AdvertiseData] + """ + return self._scan_data + + @scan_data.setter + def scan_data(self, value): + self._scan_data = value + self._update_advertising_set() + + @property + def interval(self): + """ + :setter: Update the advertising interval + :type: int + """ + return self._interval + + @interval.setter + def interval(self, value): + self._interval = value + self._update_advertising_set() + + @property + def tx_power(self): + """ + :setter: Update the transmission power level + :type: int + """ + return self._tx_power + + @tx_power.setter + def tx_power(self, value): + self._tx_power = value + self._update_advertising_set() + + @property + def bluetooth_le_advertiser(self) -> Optional[BluetoothLeAdvertiser]: + adapter = self._ble.adapter + return adapter and adapter.getBluetoothLeAdvertiser() + + @property + def parameters(self) -> AdvertisingSetParametersBuilder: + builder = AdvertisingSetParametersBuilder() + builder.setLegacyMode(True) \ + .setConnectable(False) \ + .setScannable(True) \ + .setInterval(self._interval) \ + .setTxPowerLevel(self._tx_power) + return builder.build() + + def start(self): + """Start advertising. + + Start a system activity that allows the user to turn on Bluetooth if Bluetooth is not enabled. + """ + if not self._advertising_set: + self._ble._start_advertising(self) + + def stop(self): + """Stop advertising.""" + advertiser = self.bluetooth_le_advertiser + if advertiser: + advertiser.stopAdvertisingSet(self._callback_set) + + def on_advertising_started(self, advertising_set: AdvertisingSet, tx_power: int, status: Status): + """Handler for advertising start operation (onAdvertisingSetStarted). + """ + + def on_advertising_stopped(self, advertising_set: AdvertisingSet): + """Handler for advertising stop operation (onAdvertisingSetStopped).""" + + def on_advertising_enabled(self, advertising_set: AdvertisingSet, enable: bool, status: Status): + """Handler for advertising enable/disable operation (onAdvertisingEnabled).""" + + def on_advertising_data_set(self, advertising_set: AdvertisingSet, status: Status): + """Handler for data set operation (onAdvertisingDataSet).""" + + def on_scan_response_data_set(self, advertising_set: AdvertisingSet, status: Status): + """Handler for scan response data set operation (onScanResponseDataSet).""" + + def on_advertising_parameters_updated(self, advertising_set: AdvertisingSet, tx_power: int, status: Status): + """Handler for parameters set operation (onAdvertisingParametersUpdated).""" + + def on_advertising_set_changed(self, advertising_set): + self._advertising_set = advertising_set + + def _start(self): + advertiser = self.bluetooth_le_advertiser + if advertiser: + self._callback_set = self._advertiser.createCallback() + try: + advertiser.startAdvertisingSet( + self.parameters, + self._data and self._data.data, + self._scan_data and self._scan_data.data, + None, # periodicParameters + None, # periodicData + self._callback_set + ) + except JavaException as exc: + if exc.classname == 'java.lang.IllegalArgumentException' and \ + exc.innermessage.endswith('data too big'): + self.dispatch('on_advertising_started', None, 0, Status.DATA_TOO_LARGE) + raise + + def _update_advertising_set(self): + advertising_set = self._advertising_set + if advertising_set: + advertising_set.setAdvertisingParameters(self.parameters) + advertising_set.setScanResponseData(self._scan_data and self._scan_data.data) + advertising_set.setAdvertisingData(self._data and self._data.data) diff --git a/libs/able/able/android/__init__.py b/libs/able/able/android/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/able/able/android/dispatcher.py b/libs/able/able/android/dispatcher.py new file mode 100644 index 0000000..ea90759 --- /dev/null +++ b/libs/able/able/android/dispatcher.py @@ -0,0 +1,105 @@ +from jnius import JavaException, autoclass +from kivy.logger import Logger + +from able.adapter import ( + AdapterManager, + require_bluetooth_enabled, + set_adapter_failure_rollback, +) +from able.android.jni import PythonBluetooth +from able.dispatcher import BluetoothDispatcherBase +from able.scan_settings import ScanSettingsBuilder + +ArrayList = autoclass("java.util.ArrayList") + +try: + BLE = autoclass("org.able.BLE") +except: + Logger.error( + "able_recipe: Failed to load Java class org.able.BLE. Possible build error." + ) + raise +else: + Logger.info("able_recipe: org.able.BLE Java class loaded") + +BluetoothAdapter = autoclass("android.bluetooth.BluetoothAdapter") +BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice") +BluetoothGattDescriptor = autoclass("android.bluetooth.BluetoothGattDescriptor") + +ENABLE_NOTIFICATION_VALUE = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE +ENABLE_INDICATION_VALUE = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE +DISABLE_NOTIFICATION_VALUE = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + + +class BluetoothDispatcher(BluetoothDispatcherBase): + @property + @require_bluetooth_enabled + def adapter(self): + return AdapterManager.get_attached_manager(self).adapter + + @property + def bonded_devices(self): + ble_types = (BluetoothDevice.DEVICE_TYPE_LE, BluetoothDevice.DEVICE_TYPE_DUAL) + adapter = self.adapter + devices = adapter.getBondedDevices().toArray() if adapter else [] + return [dev for dev in devices if dev.getType() in ble_types] + + def _set_ble_interface(self): + self._events_interface = PythonBluetooth(self) + self._ble = BLE(self._events_interface) + + @set_adapter_failure_rollback( + lambda self: self.dispatch("on_scan_started", success=False) + ) + @require_bluetooth_enabled + def start_scan(self, filters=None, settings=None): + filters_array = ArrayList() + for f in filters or []: + filters_array.add(f.build()) + if not settings: + settings = ScanSettingsBuilder() + try: + settings = settings.build() + except AttributeError: + pass + self._ble.startScan(self.enable_ble_code, filters_array, settings) + + def stop_scan(self): + self._ble.stopScan() + + @require_bluetooth_enabled + def connect_by_device_address(self, address: str, autoconnect: bool = False): + address = address.upper() + if not BluetoothAdapter.checkBluetoothAddress(address): + raise ValueError(f"{address} is not a valid Bluetooth address") + adapter = self.adapter + if adapter: + self.connect_gatt(adapter.getRemoteDevice(address), autoconnect) + + @require_bluetooth_enabled + def enable_notifications(self, characteristic, enable=True, indication=False): + if not self.gatt.setCharacteristicNotification(characteristic, enable): + return False + + if not enable: + # DISABLE_NOTIFICAITON_VALUE is for disabling + # both notifications and indications + descriptor_value = DISABLE_NOTIFICATION_VALUE + elif indication: + descriptor_value = ENABLE_INDICATION_VALUE + else: + descriptor_value = ENABLE_NOTIFICATION_VALUE + + for descriptor in characteristic.getDescriptors().toArray(): + self.write_descriptor(descriptor, descriptor_value) + return True + + @require_bluetooth_enabled + def _start_advertising(self, advertiser): + advertiser._start() + + @require_bluetooth_enabled + def _set_name(self, value): + adapter = self.adapter + if adapter: + self.adapter.setName(value) diff --git a/libs/able/able/android/jni.py b/libs/able/able/android/jni.py new file mode 100644 index 0000000..bac7c50 --- /dev/null +++ b/libs/able/able/android/jni.py @@ -0,0 +1,151 @@ +from able import GATT_SUCCESS +from able.structures import Advertisement, Services +from jnius import PythonJavaClass, java_method +from kivy.logger import Logger + + +class PythonBluetooth(PythonJavaClass): + __javainterfaces__ = ['org.able.PythonBluetooth'] + __javacontext__ = 'app' + + def __init__(self, dispatcher): + super(PythonBluetooth, self).__init__() + self.dispatcher = dispatcher + + @java_method('(Ljava/lang/String;)V') + def on_error(self, msg): + Logger.debug("on_error") + self.dispatcher.dispatch('on_error', msg) + + @java_method('(Landroid/bluetooth/le/ScanResult;)V') + def on_scan_result(self, result): + device = result.getDevice() # type: android.bluetooth.BluetoothDevice + record = result.getScanRecord() # type: android.bluetooth.le.ScanRecord + if record: + self.dispatcher.dispatch( + 'on_device', + device, + result.getRssi(), + Advertisement(record.getBytes()) + ) + else: + Logger.warning( + "Scan result for device without the scan record: %s", + device + ) + + @java_method('(Z)V') + def on_scan_started(self, success): + Logger.debug("on_scan_started") + self.dispatcher.dispatch('on_scan_started', success) + + @java_method('()V') + def on_scan_completed(self): + Logger.debug("on_scan_completed") + self.dispatcher.dispatch('on_scan_completed') + + @java_method('(II)V') + def on_connection_state_change(self, status, state): + Logger.debug("on_connection_state_change status={} state: {}".format( + status, state)) + self.dispatcher.dispatch('on_connection_state_change', status, state) + + @java_method('(I)V') + def on_bluetooth_adapter_state_change(self, state): + Logger.debug("on_bluetooth_adapter_state_change state: {}".format(state)) + self.dispatcher.dispatch('on_bluetooth_adapter_state_change', state) + + @java_method('(ILjava/util/List;)V') + def on_services(self, status, services): + services_dict = Services() + if status == GATT_SUCCESS: + for service in services.toArray(): + service_uuid = service.getUuid().toString() + Logger.debug("Service discovered: {}".format(service_uuid)) + services_dict[service_uuid] = {} + for c in service.getCharacteristics().toArray(): + characteristic_uuid = c.getUuid().toString() + Logger.debug("Characteristic discovered: {}".format( + characteristic_uuid)) + services_dict[service_uuid][characteristic_uuid] = c + self.dispatcher.dispatch('on_services', status, services_dict) + + @java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;)V') + def on_characteristic_changed(self, characteristic): + # uuid = characteristic.getUuid().toString() + self.dispatcher.dispatch('on_characteristic_changed', characteristic) + + @java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;I)V') + def on_characteristic_read(self, characteristic, status): + self.dispatcher.dispatch('on_gatt_release') + # uuid = characteristic.getUuid().toString() + self.dispatcher.dispatch('on_characteristic_read', + characteristic, + status) + + @java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;I)V') + def on_characteristic_write(self, characteristic, status): + self.dispatcher.dispatch('on_gatt_release') + # uuid = characteristic.getUuid().toString() + self.dispatcher.dispatch('on_characteristic_write', + characteristic, + status) + + @java_method('(Landroid/bluetooth/BluetoothGattDescriptor;I)V') + def on_descriptor_read(self, descriptor, status): + self.dispatcher.dispatch('on_gatt_release') + # characteristic = descriptor.getCharacteristic() + # uuid = characteristic.getUuid().toString() + self.dispatcher.dispatch('on_descriptor_read', descriptor, status) + + @java_method('(Landroid/bluetooth/BluetoothGattDescriptor;I)V') + def on_descriptor_write(self, descriptor, status): + self.dispatcher.dispatch('on_gatt_release') + # characteristic = descriptor.getCharacteristic() + # uuid = characteristic.getUuid().toString() + self.dispatcher.dispatch('on_descriptor_write', descriptor, status) + + @java_method('(II)V') + def on_rssi_updated(self, rssi, status): + self.dispatcher.dispatch('on_gatt_release') + self.dispatcher.dispatch('on_rssi_updated', rssi, status) + + @java_method('(II)V') + def on_mtu_changed(self, mtu, status): + self.dispatcher.dispatch('on_gatt_release') + self.dispatcher.dispatch('on_mtu_changed', mtu, status) + + +class PythonBluetoothAdvertiser(PythonJavaClass): + __javainterfaces__ = ['org.able.PythonBluetoothAdvertiser'] + __javacontext__ = 'app' + + def __init__(self, dispatcher): + super().__init__() + self.dispatcher = dispatcher + + @java_method('(Landroid/bluetooth/le/AdvertisingSet;II)V') + def on_advertising_started(self, advertising_set, tx_power, status): + self.dispatcher.dispatch('on_advertising_set_changed', advertising_set) + self.dispatcher.dispatch('on_advertising_started', advertising_set, tx_power, status) + + @java_method('(Landroid/bluetooth/le/AdvertisingSet;)V') + def on_advertising_stopped(self, advertising_set): + self.dispatcher.dispatch('on_advertising_set_changed', None) + self.dispatcher.dispatch('on_advertising_stopped', advertising_set) + + @java_method('(Landroid/bluetooth/le/AdvertisingSet;BI)V') + def on_advertising_enabled(self, advertising_set, enable, status): + self.dispatcher.dispatch('on_advertising_enabled', advertising_set, enable, status) + + @java_method('(Landroid/bluetooth/le/AdvertisingSet;I)V') + def on_advertising_data_set(self, advertising_set, status): + self.dispatcher.dispatch('on_advertising_data_set', advertising_set, status) + + @java_method('(Landroid/bluetooth/le/AdvertisingSet;I)V') + def on_scan_response_data_set(self, advertising_set, status): + self.dispatcher.dispatch('on_scan_response_data_set', advertising_set, status) + + @java_method('(Landroid/bluetooth/le/AdvertisingSet;II)V') + def on_advertising_parameters_updated(self, advertising_set, tx_power, status): + self.dispatcher.dispatch('on_advertising_parameters_updated', advertising_set, tx_power, status) diff --git a/libs/able/able/dispatcher.py b/libs/able/able/dispatcher.py new file mode 100644 index 0000000..7efff78 --- /dev/null +++ b/libs/able/able/dispatcher.py @@ -0,0 +1,371 @@ +from typing import List, Optional + +from kivy.event import EventDispatcher +from kivy.logger import Logger + +from able import WriteType +from able.adapter import AdapterManager +from able.filters import Filter +from able.permissions import DEFAULT_RUNTIME_PERMISSIONS +from able.queue import BLEQueue, ble_task, ble_task_done +from able.scan_settings import ScanSettingsBuilder +from able.utils import force_convertible_to_java_array + + +class BLEError: + """Raise Exception on attribute access""" + + def __init__(self, msg): + self.msg = msg + + def __getattr__(self, name): + raise Exception(self.msg) + + +class BluetoothDispatcherBase(EventDispatcher): + __events__ = ( + "on_device", + "on_scan_started", + "on_scan_completed", + "on_services", + "on_connection_state_change", + "on_bluetooth_adapter_state_change", + "on_characteristic_changed", + "on_characteristic_read", + "on_characteristic_write", + "on_descriptor_read", + "on_descriptor_write", + "on_gatt_release", + "on_error", + "on_rssi_updated", + "on_mtu_changed", + ) + queue_class = BLEQueue + + def __init__( + self, + queue_timeout: float = 0.5, + enable_ble_code: int = 0xAB1E, + runtime_permissions: Optional[list[str]] = None, # DEFAULT_RUNTIME_PERMISSIONS + ): + super(BluetoothDispatcherBase, self).__init__() + self.queue_timeout = queue_timeout + self.enable_ble_code = enable_ble_code + self.runtime_permissions = [ + str(permission) + for permission in ( + runtime_permissions + if runtime_permissions is not None + else DEFAULT_RUNTIME_PERMISSIONS + ) + ] + self._remote_device_address = None + self._set_ble_interface() + self._set_queue() + self._set_adapter_manager() + + def _set_ble_interface(self): + self._ble = BLEError("BLE is not implemented for platform") + + def _set_queue(self): + self.queue = self.queue_class(timeout=self.queue_timeout) + + def _set_adapter_manager(self): + AdapterManager( + ble=self._ble, + enable_ble_code=self.enable_ble_code, + runtime_permissions=self.runtime_permissions, + ).install(self) + + def _check_runtime_permissions(self): + return True + + def _request_runtime_permissions(self): + pass + + @property + def adapter(self) -> Optional["android.bluetooth.BluetoothAdapter"]: + """Local device Bluetooth adapter. + Could be `None` if adapter is not enabled or access to the adapter is not granted yet. + + :type: `BluetoothAdapter `_ + `Java object `_ + """ + + @property + def gatt(self): + """GATT profile of the connected device + + :type: BluetoothGatt Java object + """ + return self._ble.getGatt() + + @property + def bonded_devices(self): + """List of Java `android.bluetooth.BluetoothDevice` objects of paired BLE devices. + + :type: List[BluetoothDevice] + """ + return [] + + @property + def name(self): + """Name of the Bluetooth adapter. + + :setter: Set name of the Bluetooth adapter + :type: Optional[str] + """ + adapter = self.adapter + return adapter and adapter.getName() + + @name.setter + def name(self, value): + self._set_name(value) + + def _set_name(self, value): + pass + + def set_queue_timeout(self, timeout): + """Change the BLE operations queue timeout""" + self.queue_timeout = timeout + self.queue.set_timeout(timeout) + + def start_scan( + self, + filters: Optional[List[Filter]] = None, + settings: Optional[ScanSettingsBuilder] = None, + ): + """Start a scan for devices. + The status of the scan start are reported with + :func:`scan_started ` event. + + :param filters: list of filters to restrict scan results. + Advertising record is considered matching the filters + if it matches any of the :class:`able.filters.Filter` in the list. + :param settings: scan settings + """ + pass + + def stop_scan(self): + """Stop the ongoing scan for devices.""" + pass + + def connect_by_device_address(self, address: str, autoconnect: bool = False): + """Connect to GATT Server of the device with a given Bluetooth hardware address, without scanning. + + :param address: Bluetooth hardware address string in "XX:XX:XX:XX:XX:XX" format + :param autoconnect: If True, automatically reconnects when available. + False = direct connect (default). + :raises: + ValueError: if `address` is not a valid Bluetooth address + """ + pass + + def connect_gatt(self, device, autoconnect: bool = False): + """Connect to GATT Server hosted by device + + :param device: BluetoothDevice Java object + :param autoconnect: If True, automatically reconnects when available. + False = direct connect (default). + """ + self._ble.connectGatt(device, autoconnect) + + def close_gatt(self): + """Close current GATT client""" + self._ble.closeGatt() + + def discover_services(self): + """Discovers services offered by a remote device. + The status of the discovery reported with + :func:`services ` event. + + :return: true, if the remote services discovery has been started + """ + return self.gatt.discoverServices() + + def enable_notifications(self, characteristic, enable=True, indication=False): + """Enable/disable notifications or indications for a given characteristic + + :param characteristic: BluetoothGattCharacteristic Java object + :param enable: enable notifications if True, else disable notifications + :param indication: handle indications instead of notifications + :return: True, if the operation was initiated successfully + """ + return True + + @ble_task + def write_descriptor(self, descriptor, value): + """Set and write the value of a given descriptor to the associated + remote device + + :param descriptor: BluetoothGattDescriptor Java object + :param value: value to write + """ + if not descriptor.setValue(force_convertible_to_java_array(value)): + Logger.error("Error on set descriptor value") + return + if not self.gatt.writeDescriptor(descriptor): + Logger.error("Error on descriptor write") + + @ble_task + def write_characteristic( + self, characteristic, value, write_type: Optional[WriteType] = None + ): + """Write a given characteristic value to the associated remote device + + :param characteristic: BluetoothGattCharacteristic Java object + :param value: value to write + :param write_type: specific write type to set for the characteristic + """ + self._ble.writeCharacteristic( + characteristic, force_convertible_to_java_array(value), int(write_type or 0) + ) + + @ble_task + def read_characteristic(self, characteristic): + """Read a given characteristic from the associated remote device + + :param characteristic: BluetoothGattCharacteristic Java object + """ + self._ble.readCharacteristic(characteristic) + + @ble_task + def update_rssi(self): + """Triggers an update for the RSSI from the associated remote device""" + self._ble.readRemoteRssi() + + @ble_task + def request_mtu(self, mtu: int): + """Request to change the ATT Maximum Transmission Unit value + + :param value: new MTU size + """ + self.gatt.requestMtu(mtu) + + def on_error(self, msg): + """Error handler + + :param msg: error message + """ + self._ble = BLEError(msg) # Exception for calls from another threads + raise Exception(msg) + + @ble_task_done + def on_gatt_release(self): + """`gatt_release` event handler. + Event is dispatched at every read/write completed operation + """ + pass + + def on_scan_started(self, success): + """`scan_started` event handler + + :param success: true, if scan was started successfully + """ + pass + + def on_scan_completed(self): + """`scan_completed` event handler""" + pass + + def on_device(self, device, rssi, advertisement): + """`device` event handler. + Event is dispatched when device is found during a scan. + + :param device: BluetoothDevice Java object + :param rssi: the RSSI value for the remote device + :param advertisement: :class:`Advertisement` data record + """ + pass + + def on_connection_state_change(self, status, state): + """`connection_state_change` event handler + + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + :param state: STATE_CONNECTED or STATE_DISCONNECTED + """ + pass + + def on_bluetooth_adapter_state_change(self, state): + """`bluetooth_adapter_state_change` event handler + Allows the user to detect when bluetooth adapter is turned on/off. + + :param state: STATE_OFF, STATE_TURNING_OFF, STATE_ON, STATE_TURNING_ON + """ + + def on_services(self, services, status): + """`services` event handler + + :param services: :class:`Services` dict filled with discovered + characteristics + (BluetoothGattCharacteristic Java objects) + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + """ + pass + + def on_characteristic_changed(self, characteristic): + """`characteristic_changed` event handler + + :param characteristic: BluetoothGattCharacteristic Java object + """ + pass + + def on_characteristic_read(self, characteristic, status): + """`characteristic_read` event handler + + :param characteristic: BluetoothGattCharacteristic Java object + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + """ + pass + + def on_characteristic_write(self, characteristic, status): + """`characteristic_write` event handler + + :param characteristic: BluetoothGattCharacteristic Java object + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + """ + pass + + def on_descriptor_read(self, descriptor, status): + """`descriptor_read` event handler + + :param descriptor: BluetoothGattDescriptor Java object + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + """ + pass + + def on_descriptor_write(self, descriptor, status): + """`descriptor_write` event handler + + :param descriptor: BluetoothGattDescriptor Java object + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + """ + pass + + def on_rssi_updated(self, rssi, status): + """`onReadRemoteRssi` event handler. + Event is dispatched at every RSSI update completed operation, + reporting a RSSI value for a remote device connection. + + :param rssi: integer containing RSSI value in dBm + :param status: status of the operation, + `GATT_SUCCESS` if the operation succeeds + """ + pass + + def on_mtu_changed(self, mtu, status): + """`onMtuChanged` event handler + Event is dispatched when MTU for a remote device has changed, + reporting a new MTU size. + + :param mtu: integer containing the new MTU size + :param status: status of the operation, + `GATT_SUCCESS` if the MTU has been changed successfully + """ + pass diff --git a/libs/able/able/filters.py b/libs/able/able/filters.py new file mode 100644 index 0000000..0b362f3 --- /dev/null +++ b/libs/able/able/filters.py @@ -0,0 +1,237 @@ +"""BLE scanning filters, +wrappers for Java class `android.bluetooth.le.ScanFilter.Builder` +https://developer.android.com/reference/android/bluetooth/le/ScanFilter.Builder +""" +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import List, Union +import uuid + +from jnius import autoclass + +ParcelUuid = autoclass('android.os.ParcelUuid') +BluetoothAdapter = autoclass('android.bluetooth.BluetoothAdapter') +ScanFilter = autoclass('android.bluetooth.le.ScanFilter') +ScanFilterBuilder = autoclass('android.bluetooth.le.ScanFilter$Builder') + + +@dataclass +class Filter: + """Base class for BLE scanning fiters. + + >>> # Filters of different kinds could be ANDed to set multiple conditions. + >>> # Both device name and address required: + >>> combined_filter = DeviceNameFilter("Example") & DeviceAddressFilter("01:02:03:AB:CD:EF") + + >>> DeviceNameFilter("Example1") & DeviceNameFilter("Example2") + Traceback (most recent call last): + ValueError: cannot combine filters of the same type + """ + + def __post_init__(self): + self.filters = [self] + + def __and__(self, other): + if type(self) in (type(f) for f in other.filters): + raise ValueError('cannot combine filters of the same type') + self.filters.extend(other.filters) + return self + + def build(self): + builder = ScanFilterBuilder() + for scan_filter in self.filters: + scan_filter.filter(builder) + return builder.build() + + @abstractmethod + def filter(self, builder): + pass + + +class EmptyFilter(Filter): + """Filter with no restrictions.""" + + def filter(self, builder): + pass + + +@dataclass +class DeviceAddressFilter(Filter): + """Set filter on device address. + Uses Java method `ScanFilter.Builder.setDeviceAddress`. + + :param address: Address in the format of "01:02:03:AB:CD:EF" + + >>> DeviceAddressFilter("01:02:03:AB:CD:EF") + DeviceAddressFilter(address='01:02:03:AB:CD:EF') + """ + address: str + + def __post_init__(self): + super().__post_init__() + if not BluetoothAdapter.checkBluetoothAddress(str(self.address)): + raise ValueError(f"{self.address} is not a valid Bluetooth address") + + def filter(self, builder): + builder.setDeviceAddress(str(self.address)) + + +@dataclass +class DeviceNameFilter(Filter): + """Set filter on device name. + Uses Java method `ScanFilter.Builder.setDeviceName`. + + :param name: Device name + """ + name: str + + def filter(self, builder): + builder.setDeviceName(str(self.name)) + + +@dataclass +class ManufacturerDataFilter(Filter): + """Set filter on manufacture data. + Uses Java method `ScanFilter.Builder.setManufacturerData`. + + :param id: Manufacturer ID + :param data: Manufacturer specific data + :param mask: bit mask for partial filtration of the `data`. For any bit in the mask, + set it to 1 if it needs to match the one in manufacturer data, + otherwise set it to 0 to ignore that bit. + + + >>> # Filter by just ID, ignoring the data: + >>> ManufacturerDataFilter(0x0AD0, []) + ManufacturerDataFilter(id=2768, data=[], mask=None) + + >>> ManufacturerDataFilter(0x0AD0, [0x2, 0x15, 0x8d]) + ManufacturerDataFilter(id=2768, data=[2, 21, 141], mask=None) + + >>> # With mask set to ignore the second data byte: + >>> ManufacturerDataFilter(0x0AD0, [0x2, 0, 0x8d], [0xff, 0, 0xff]) + ManufacturerDataFilter(id=2768, data=[2, 0, 141], mask=[255, 0, 255]) + + >>> ManufacturerDataFilter(0x0AD0, [0x2, 21, 0x8d], [0xff]) + Traceback (most recent call last): + ValueError: mask is shorter than the data + """ + id: int + data: Union[list, tuple, bytes, bytearray] + mask: List[int] = field(default_factory=lambda: None) + + def __post_init__(self): + super().__post_init__() + if self.mask and len(self.mask) < len(self.data): + raise ValueError('mask is shorter than the data') + + def filter(self, builder): + if self.mask: + builder.setManufacturerData(self.id, self.data, self.mask) + else: + builder.setManufacturerData(self.id, self.data) + + +@dataclass +class ServiceDataFilter(Filter): + """Set filter on service data. + Uses Java method `ScanFilter.Builder.setServiceData`. + + :param uid: UUID of the service in the format of + "0000180f-0000-1000-8000-00805f9b34fb" + :param data: service data + :param mask: bit mask for partial filtration of the `data`. For any bit in the mask, + set it to 1 if it needs to match the one in service data, + otherwise set it to 0 to ignore that bit. + + >>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", []) + ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[], mask=None) + + >>> # With mask set to ignore the first data byte: + >>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0, 0x11], [0, 0xff]) + ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[0, 17], mask=[0, 255]) + + >>> ServiceDataFilter("0000180f", []) + Traceback (most recent call last): + ValueError: badly formed hexadecimal UUID string + + >>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x12, 0x34], [0xff]) + Traceback (most recent call last): + ValueError: mask is shorter than the data + """ + uid: str + data: Union[list, tuple, bytes, bytearray] + mask: List[int] = field(default_factory=lambda: None) + + def __post_init__(self): + super().__post_init__() + # validate UUID value + uuid.UUID(self.uid) + if self.mask and len(self.mask) < len(self.data): + raise ValueError('mask is shorter than the data') + + def filter(self, builder): + uid = ParcelUuid.fromString(self.uid) + if self.mask: + builder.setServiceData(uid, self.data, self.mask) + else: + builder.setServiceData(uid, self.data) + + +@dataclass +class ServiceSolicitationFilter(Filter): + """Set filter on service solicitation uuid. + Uses Java method `ScanFilter.Builder.setServiceSolicitation`. + + :param uid: UUID of the service in the format of + "0000180f-0000-1000-8000-00805f9b34fb" + """ + uid: str + + def filter(self, builder): + uid = ParcelUuid.fromString(self.uid) + builder.setServiceSolicitation(uid) + + +@dataclass +class ServiceUUIDFilter(Filter): + """Set filter on service uuid. + Uses Java method `ScanFilter.Builder.setServiceUuid`. + + :param uid: UUID of the service in the format of + "0000180f-0000-1000-8000-00805f9b34fb" + :mask: bit mask for partial filtration of the UUID, in the format of + "ffffffff-0000-0000-0000-ffffffffffff". Set any bit in the mask + to 1 to indicate a match is needed for the bit in `uid`, + and 0 to ignore that bit. + + >>> ServiceUUIDFilter('16fe0d00-c111-11e3-b8c8-0002a5d5c51b') + ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51b', mask=None) + + >>> ServiceUUIDFilter( + ... '16fe0d00-c111-11e3-b8c8-0002a5d5c51b', + ... 'ffffffff-0000-0000-0000-000000000000' + ... ) #doctest: +ELLIPSIS + ServiceUUIDFilter(uid='16fe0d00-...', mask='ffffffff-...') + + >>> ServiceUUIDFilter('123') + Traceback (most recent call last): + ValueError: badly formed hexadecimal UUID string + """ + uid: str + mask: str = None + + def __post_init__(self): + super().__post_init__() + # validate UUID values + uuid.UUID(self.uid) + if self.mask: + uuid.UUID(self.mask) + + def filter(self, builder): + uid = ParcelUuid.fromString(self.uid) + if self.mask: + mask = ParcelUuid.fromString(self.mask) + builder.setServiceUuid(uid, mask) + else: + builder.setServiceUuid(uid) diff --git a/libs/able/able/permissions.py b/libs/able/able/permissions.py new file mode 100644 index 0000000..862e79e --- /dev/null +++ b/libs/able/able/permissions.py @@ -0,0 +1,53 @@ +"""Before executing, all :class:`BluetoothDispatcher ` methods that requires Bluetooth adapter +(`start_scan`, `connect_by_device_address`, `enable_notifications`, `adapter` property ...), +are asking the user to: + +#. grant runtime permissions, +#. turn on Bluetooth adapter. + +The list of requested runtime permissions varies depending on the level of the target Android API level: + +* target API level <=30: ACCESS_FINE_LOCATION - to obtain BLE scan results +* target API level >= 31: + + * BLUETOOTH_CONNECT - to enable adapter and to connect to devices + * BLUETOOTH_SCAN - to start the scan + * ACCESS_FINE_LOCATION - to detect beacons during the scan + * BLUETOOTH_ADVERTISE - to be able to advertise to nearby Bluetooth devices + +Requested permissions list can be changed with the `BluetoothDispatcher.runtime_permissions` parameter. +""" +from jnius import autoclass + +SDK_INT = int(autoclass("android.os.Build$VERSION").SDK_INT) + + +class Permission: + """ + String constants values for BLE-related permissions. + https://developer.android.com/reference/android/Manifest.permission + """ + + ACCESS_BACKGROUND_LOCATION = "android.permission.ACCESS_BACKGROUND_LOCATION" + ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION" + BLUETOOTH_ADVERTISE = "android.permission.BLUETOOTH_ADVERTISE" + BLUETOOTH_CONNECT = "android.permission.BLUETOOTH_CONNECT" + BLUETOOTH_SCAN = "android.permission.BLUETOOTH_SCAN" + + +if SDK_INT >= 31: + # API level 31 (Android 12) introduces new permissions + DEFAULT_RUNTIME_PERMISSIONS = [ + Permission.BLUETOOTH_ADVERTISE, + Permission.BLUETOOTH_CONNECT, + Permission.BLUETOOTH_SCAN, + # ACCESS_FINE_LOCATION is not mandatory for scan, + # but required to discover beacons + Permission.ACCESS_FINE_LOCATION, + ] +else: + # For API levels 29-30, + # ACCESS_FINE_LOCATION permission is needed to obtain BLE scan results + DEFAULT_RUNTIME_PERMISSIONS = [ + Permission.ACCESS_FINE_LOCATION, + ] diff --git a/libs/able/able/queue.py b/libs/able/able/queue.py new file mode 100644 index 0000000..8d30124 --- /dev/null +++ b/libs/able/able/queue.py @@ -0,0 +1,90 @@ +import threading +from functools import wraps, partial +try: + from queue import Empty, Queue +except ImportError: + from Queue import Empty, Queue +from kivy.clock import Clock +from kivy.logger import Logger + + +def ble_task(method): + """ + Enque method + """ + @wraps(method) + def wrapper(obj, *args, **kwargs): + task = partial(method, obj, *args, **kwargs) + obj.queue.enque(task) + return wrapper + + +def ble_task_done(method): + @wraps(method) + def wrapper(obj, *args, **kwargs): + obj.queue.done(*args, **kwargs) + method(obj, *args, **kwargs) + return wrapper + + +def with_lock(method): + @wraps(method) + def wrapped(obj, *args, **kwargs): + locked = obj.lock.acquire(False) + if locked: + try: + return method(obj, *args, **kwargs) + finally: + obj.lock.release() + return wrapped + + +class BLEQueue(object): + + def __init__(self, timeout=0): + self.lock = threading.Lock() + self.ready = True + self.queue = Queue() + self.set_timeout(timeout) + + def set_timeout(self, timeout): + Logger.debug("set queue timeout to {}".format(timeout)) + self.timeout = timeout + self.timeout_event = Clock.schedule_once( + self.on_timeout, self.timeout or 0) + self.timeout_event.cancel() + + def enque(self, task): + queue = self.queue + if self.timeout == 0: + self.execute_task(task) + else: + queue.put_nowait(task) + self.execute_next() + + @with_lock + def execute_next(self, ready=False): + if ready: + self.ready = True + elif not self.ready: + return + try: + task = self.queue.get_nowait() + except Empty: + return + self.ready = False + if task is not None: + self.execute_task(task) + + def done(self, *args, **kwargs): + self.timeout_event.cancel() + self.ready = True + self.execute_next() + + def on_timeout(self, *args, **kwargs): + self.done() + + def execute_task(self, task): + if self.timeout and self.timeout_event: + self.timeout_event() + task() diff --git a/libs/able/able/scan_settings.py b/libs/able/able/scan_settings.py new file mode 100644 index 0000000..e5b1201 --- /dev/null +++ b/libs/able/able/scan_settings.py @@ -0,0 +1,20 @@ +"""BLE scanning settings. +""" +from jnius import autoclass +from kivy.utils import platform + + +if platform != 'android': + class ScanSettings: + """PyJNIus wrapper for Java class `android.bluetooth.le.ScanSettings`. + https://developer.android.com/reference/android/bluetooth/le/ScanSettings + """ + + class ScanSettingsBuilder: + """PyJNIus wrapper for Java class `android.bluetooth.le.ScanSettings.Builder`. + https://developer.android.com/reference/android/bluetooth/le/ScanSettings.Builder + """ + +else: + ScanSettings = autoclass('android.bluetooth.le.ScanSettings') + ScanSettingsBuilder = autoclass('android.bluetooth.le.ScanSettings$Builder') diff --git a/libs/able/able/src/org/able/BLE.java b/libs/able/able/src/org/able/BLE.java new file mode 100644 index 0000000..25771bb --- /dev/null +++ b/libs/able/able/src/org/able/BLE.java @@ -0,0 +1,283 @@ +package org.able; + +import android.app.Activity; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanResult; + +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanSettings; + +import android.os.Handler; +import android.util.Log; +import java.util.List; +import org.kivy.android.PythonActivity; +import org.kivy.android.PythonService; +import org.able.PythonBluetooth; + + +public class BLE { + private String TAG = "BLE-python"; + private PythonBluetooth mPython; + private Context mContext; + private BluetoothAdapter mBluetoothAdapter; + private BluetoothLeScanner mBluetoothLeScanner; + private BluetoothGatt mBluetoothGatt; + private List mBluetoothGattServices; + private boolean mScanning; + private boolean mIsServiceContext = false; + + public void showError(final String msg) { + Log.e(TAG, msg); + if (!mIsServiceContext) { PythonActivity.mActivity.toastError(TAG + " error. " + msg); } + mPython.on_error(msg); + } + + public BLE(PythonBluetooth python) { + mPython = python; + mContext = (Context) PythonActivity.mActivity; + mBluetoothGatt = null; + + if (mContext == null) { + Log.d(TAG, "Service context detected"); + mIsServiceContext = true; + mContext = (Context) PythonService.mService; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { + showError("Device does not support Bluetooth Low Energy."); + return; + } + + final BluetoothManager bluetoothManager = + (BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE); + mBluetoothAdapter = bluetoothManager.getAdapter(); + mContext.registerReceiver(mReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)); + } + + public BluetoothAdapter getAdapter(int EnableBtCode) { + if (mBluetoothAdapter == null) { + showError("Device do not support Bluetooth Low Energy."); + return null; + } + if (!mBluetoothAdapter.isEnabled()) { + if (mIsServiceContext) { + showError("BLE adapter is not enabled"); + } else { + Log.d(TAG, "BLE adapter is not enabled"); + Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + PythonActivity.mActivity.startActivityForResult(enableBtIntent, EnableBtCode); + } + return null; + } + return mBluetoothAdapter; + } + + public BluetoothGatt getGatt() { + return mBluetoothGatt; + } + + public void startScan(int EnableBtCode, + List filters, + ScanSettings settings) { + Log.d(TAG, "startScan"); + BluetoothAdapter adapter = getAdapter(EnableBtCode); + if (adapter != null) { + Log.d(TAG, "BLE adapter is ready for scan"); + if (mBluetoothLeScanner == null) { + mBluetoothLeScanner = adapter.getBluetoothLeScanner(); + } + if (mBluetoothLeScanner != null) { + mScanning = false; + mBluetoothLeScanner.startScan(filters, settings, mScanCallback); + } else { + showError("Could not get BLE Scanner object."); + mPython.on_scan_started(false); + } + } + } + + public void stopScan() { + if (mBluetoothLeScanner != null) { + Log.d(TAG, "stopScan"); + mBluetoothLeScanner.stopScan(mScanCallback); + if (mScanning) { + mScanning = false; + mPython.on_scan_completed(); + } + } + } + + private final ScanCallback mScanCallback = + new ScanCallback() { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + if (!mScanning) { + mScanning = true; + Log.d(TAG, "BLE scan started successfully"); + mPython.on_scan_started(true); + } + if (mIsServiceContext) { + mPython.on_scan_result(result); + return; + } + PythonActivity.mActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mPython.on_scan_result(result); + } + }); + } + + @Override + public void onBatchScanResults(List results) { + Log.d(TAG, "onBatchScanResults"); + } + + @Override + public void onScanFailed(int errorCode) { + Log.e(TAG, "BLE Scan failed, error code:" + errorCode); + mPython.on_scan_started(false); + } + }; + + public void connectGatt(BluetoothDevice device) { + connectGatt(device, false); + } + + public void connectGatt(BluetoothDevice device, boolean autoConnect) { + Log.d(TAG, "connectGatt"); + if (mBluetoothGatt == null) { + mBluetoothGatt = device.connectGatt(mContext, autoConnect, mGattCallback, BluetoothDevice.TRANSPORT_LE); + } else { + Log.d(TAG, "BluetoothGatt object exists, use either closeGatt() to close Gatt or BluetoothGatt.connect() to re-connect"); + } + } + + public void closeGatt() { + Log.d(TAG, "closeGatt"); + if (mBluetoothGatt != null) { + mBluetoothGatt.close(); + mBluetoothGatt = null; + } + } + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) { + Log.d(TAG, "onReceive - BluetoothAdapter state changed"); + int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1); + mPython.on_bluetooth_adapter_state_change(state); + } + } + }; + + private final BluetoothGattCallback mGattCallback = + new BluetoothGattCallback() { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Connected to GATT server, status:" + status); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Disconnected from GATT server, status:" + status); + } + if (mBluetoothGatt == null) { + mBluetoothGatt = gatt; + } + mPython.on_connection_state_change(status, newState); + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "onServicesDiscovered - success"); + mBluetoothGattServices = mBluetoothGatt.getServices(); + } else { + showError("onServicesDiscovered status:" + status); + mBluetoothGattServices = null; + } + mPython.on_services(status, mBluetoothGattServices); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic) { + mPython.on_characteristic_changed(characteristic); + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, + int status) { + mPython.on_characteristic_read(characteristic, status); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, + int status) { + mPython.on_characteristic_write(characteristic, status); + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, + BluetoothGattDescriptor descriptor, + int status) { + mPython.on_descriptor_read(descriptor, status); + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, + BluetoothGattDescriptor descriptor, + int status) { + mPython.on_descriptor_write(descriptor, status); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, + int rssi, int status) { + mPython.on_rssi_updated(rssi, status); + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, + int mtu, int status) { + Log.d(TAG, String.format("onMtuChanged mtu=%d status=%d", mtu, status)); + mPython.on_mtu_changed(mtu, status); + } + }; + + public boolean writeCharacteristic(BluetoothGattCharacteristic characteristic, byte[] data, int writeType) { + if (characteristic.setValue(data)) { + if (writeType != 0) { + characteristic.setWriteType(writeType); + } + return mBluetoothGatt.writeCharacteristic(characteristic); + } + return false; + } + + public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) { + return mBluetoothGatt.readCharacteristic(characteristic); + } + + public boolean readRemoteRssi() { + return mBluetoothGatt.readRemoteRssi(); + } +} diff --git a/libs/able/able/src/org/able/BLEAdvertiser.java b/libs/able/able/src/org/able/BLEAdvertiser.java new file mode 100644 index 0000000..fcff023 --- /dev/null +++ b/libs/able/able/src/org/able/BLEAdvertiser.java @@ -0,0 +1,61 @@ +package org.able; + +import android.bluetooth.le.AdvertisingSet; +import android.bluetooth.le.AdvertisingSetCallback; +import org.able.PythonBluetoothAdvertiser; +import android.util.Log; + + +public class BLEAdvertiser { + private String TAG = "BLE-python"; + private PythonBluetoothAdvertiser mPython; + public AdvertisingSetCallback mCallbackSet; + + public BLEAdvertiser(PythonBluetoothAdvertiser python) { + mPython = python; + } + + public AdvertisingSetCallback createCallback() { + mCallbackSet = new AdvertisingSetCallback() { + @Override + public void onAdvertisingSetStarted(AdvertisingSet advertisingSet, int txPower, int status) { + Log.d(TAG, "onAdvertisingSetStarted, status:" + status); + mPython.on_advertising_started(advertisingSet, txPower, status); + } + + @Override + public void onAdvertisingSetStopped(AdvertisingSet advertisingSet) { + Log.d(TAG, "onAdvertisingSetStopped"); + mCallbackSet = null; + mPython.on_advertising_stopped(advertisingSet); + } + + + @Override + public void onAdvertisingEnabled(AdvertisingSet advertisingSet, boolean enable, int status) { + Log.d(TAG, "onAdvertisingEnabled, enable:" + enable + "status:" + status); + mPython.on_advertising_enabled(advertisingSet, enable, status); + } + + + @Override + public void onAdvertisingDataSet(AdvertisingSet advertisingSet, int status) { + Log.d(TAG, "onAdvertisingDataSet, status:" + status); + mPython.on_advertising_data_set(advertisingSet, status); + } + + @Override + public void onScanResponseDataSet(AdvertisingSet advertisingSet, int status) { + Log.d(TAG, "onScanResponseDataSet, status:" + status); + mPython.on_scan_response_data_set(advertisingSet, status); + } + + @Override + public void onAdvertisingParametersUpdated(AdvertisingSet advertisingSet, int txPower, int status) { + Log.d(TAG, "onAdvertisingParametersUpdated, status:" + status); + mPython.on_advertising_parameters_updated(advertisingSet, txPower, status); + } + }; + return mCallbackSet; + } +} diff --git a/libs/able/able/src/org/able/PythonBluetooth.java b/libs/able/able/src/org/able/PythonBluetooth.java new file mode 100644 index 0000000..8179ca2 --- /dev/null +++ b/libs/able/able/src/org/able/PythonBluetooth.java @@ -0,0 +1,25 @@ +package org.able; + +import java.util.List; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.le.ScanResult; + +interface PythonBluetooth +{ + public void on_error(String msg); + public void on_scan_started(boolean success); + public void on_scan_result(ScanResult result); + public void on_scan_completed(); + public void on_services(int status, List services); + public void on_characteristic_changed(BluetoothGattCharacteristic characteristic); + public void on_characteristic_read(BluetoothGattCharacteristic characteristic, int status); + public void on_characteristic_write(BluetoothGattCharacteristic characteristic, int status); + public void on_descriptor_read(BluetoothGattDescriptor descriptor, int status); + public void on_descriptor_write(BluetoothGattDescriptor descriptor, int status); + public void on_connection_state_change(int status, int state); + public void on_bluetooth_adapter_state_change(int state); + public void on_rssi_updated(int rssi, int status); + public void on_mtu_changed (int mtu, int status); +} diff --git a/libs/able/able/src/org/able/PythonBluetoothAdvertiser.java b/libs/able/able/src/org/able/PythonBluetoothAdvertiser.java new file mode 100644 index 0000000..d2c6d38 --- /dev/null +++ b/libs/able/able/src/org/able/PythonBluetoothAdvertiser.java @@ -0,0 +1,13 @@ +package org.able; + +import android.bluetooth.le.AdvertisingSet; + +interface PythonBluetoothAdvertiser +{ + public void on_advertising_started(AdvertisingSet advertisingSet, int txPower, int status); + public void on_advertising_stopped(AdvertisingSet advertisingSet); + public void on_advertising_enabled(AdvertisingSet advertisingSet, boolean enable, int status); + public void on_advertising_data_set(AdvertisingSet advertisingSet, int status); + public void on_scan_response_data_set(AdvertisingSet advertisingSet, int status); + public void on_advertising_parameters_updated(AdvertisingSet advertisingSet, int txPower, int status); +} diff --git a/libs/able/able/structures.py b/libs/able/able/structures.py new file mode 100644 index 0000000..2bd3e93 --- /dev/null +++ b/libs/able/able/structures.py @@ -0,0 +1,81 @@ +import re + +from collections import namedtuple + + +class Advertisement(object): + """Advertisement data record parser + + >>> ad = Advertisement([2, 1, 0x6, 6, 255, 82, 83, 95, 82, 48]) + >>> for data in ad: + ... data + AD(ad_type=1, data=bytearray(b'\\x06')) + AD(ad_type=255, data=bytearray(b'RS_R0')) + >>> list(ad)[0].ad_type == Advertisement.ad_types.flags + True + """ + + AD = namedtuple("AD", ['ad_type', 'data']) + + class ad_types: + """ + Assigned numbers for some of `advertisement data types + `_. + + flags : "Flags" (0x01) + + complete_local_name : "Complete Local Name" (0x09) + + service_data : "Service Data" (0x16) + + manufacturer_specific_data : "Manufacturer Specific Data" (0xff) + """ + flags = 0x01 + complete_local_name = 0x09 + service_data = 0x16 + manufacturer_specific_data = 0xff + + def __init__(self, data): + self.data = data + + def __iter__(self): + return Advertisement.parse(self.data) + + @classmethod + def parse(cls, data): + pos = 0 + while pos < len(data): + length = data[pos] + if length < 2: + return + try: + ad_type = data[pos + 1] + except IndexError: + return + next_pos = pos + length + 1 + if ad_type: + segment = slice(pos + 2, next_pos) + yield Advertisement.AD(ad_type, bytearray(data[segment])) + pos = next_pos + + +class Services(dict): + """Services dict + + >>> services = Services({'service0': {'c1-aa': 0, 'aa-c2-aa': 1}, + ... 'service1': {'bb-c3-bb': 2}}) + >>> services.search('c3') + 2 + >>> services.search('c4') + """ + + def search(self, pattern, flags=re.IGNORECASE): + """Search for characteristic by pattern + + :param pattern: regexp pattern + :param flags: regexp flags, re.IGNORECASE by default + """ + for characteristics in self.values(): + for uuid, characteristic in characteristics.items(): + if re.search(pattern, uuid, flags): + return characteristic diff --git a/libs/able/able/utils.py b/libs/able/able/utils.py new file mode 100644 index 0000000..3108372 --- /dev/null +++ b/libs/able/able/utils.py @@ -0,0 +1,42 @@ +from typing import Any, Union + + +def force_convertible_to_java_array( + value: Any +) -> Union[list, tuple, bytes, bytearray]: + """Construct a value that is convertible to a Java array. + + >>> force_convertible_to_java_array([3, 1, 4]) + [3, 1, 4] + >>> force_convertible_to_java_array(['314']) + ['314'] + >>> force_convertible_to_java_array('314') + b'314' + >>> force_convertible_to_java_array(314) + [314] + >>> force_convertible_to_java_array(0) + [0] + >>> force_convertible_to_java_array('') + [] + >>> force_convertible_to_java_array(None) + [] + >>> force_convertible_to_java_array({}) + [] + """ + if isinstance(value, (list, tuple, bytes, bytearray)): + return value + + try: + return value.encode() or [] + except AttributeError: + pass + + try: + return list(value) + except TypeError: + pass + + if value is None: + return [] + + return [value] diff --git a/libs/able/able/version.py b/libs/able/able/version.py new file mode 100644 index 0000000..4ec92f4 --- /dev/null +++ b/libs/able/able/version.py @@ -0,0 +1,5 @@ +"""Package version. +This file is filled with actual value during the PyPI package build. +Development version is always "0.0.0". +""" +__version__ = '0.0.0' diff --git a/libs/able/docs/Makefile b/libs/able/docs/Makefile new file mode 100644 index 0000000..4a5e9a7 --- /dev/null +++ b/libs/able/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/ABLE.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/ABLE.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/ABLE" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/ABLE" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/libs/able/docs/api.rst b/libs/able/docs/api.rst new file mode 100644 index 0000000..5226cae --- /dev/null +++ b/libs/able/docs/api.rst @@ -0,0 +1,154 @@ +API +--- + +.. automodule:: able + +Client +^^^^^^ + +BluetoothDispatcher +""""""""""""""""""" + +.. autoclass:: BluetoothDispatcher + :members: adapter, + gatt, + bonded_devices, + name, + set_queue_timeout, + start_scan, + stop_scan, + connect_by_device_address, + connect_gatt, + close_gatt, + discover_services, + enable_notifications, + write_descriptor, + write_characteristic, + read_characteristic, + update_rssi, + request_mtu, + on_error, + on_gatt_release, + on_scan_started, + on_scan_completed, + on_device, + on_bluetooth_adapter_state_changeable, + on_connection_state_change, + on_services, + on_characteristic_changed, + on_characteristic_read, + on_characteristic_write, + on_descriptor_read, + on_descriptor_write, + on_rssi_updated, + on_mtu_changed, + +Decorators +"""""""""" + +.. autofunction:: require_bluetooth_enabled + + +Advertisement +""""""""""""" + +.. autoclass:: Advertisement + + .. autoclass:: able::Advertisement.ad_types + +Services +"""""""" + +.. autoclass:: Services + :members: + +Constants +""""""""" + +.. autodata:: GATT_SUCCESS +.. autodata:: STATE_CONNECTED +.. autodata:: STATE_DISCONNECTED +.. autoclass:: AdapterState + :members: + :member-order: bysource +.. autoclass:: WriteType + :members: + +Permissions +^^^^^^^^^^^ + +.. automodule:: able.permissions +.. automodule:: able +.. autoclass:: Permission + :members: + :undoc-members: + :member-order: bysource + + +Scan settings +^^^^^^^^^^^^^ + +.. automodule:: able.scan_settings +.. autoclass:: ScanSettingsBuilder +.. autoclass:: ScanSettings + + +>>> settings = ScanSettingsBuilder() \ +... .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) \ +... .setCallbackType( +... ScanSettings.CALLBACK_TYPE_FIRST_MATCH | +... ScanSettings.CALLBACK_TYPE_MATCH_LOST +... ) + + +Scan filters +^^^^^^^^^^^^ + +.. automodule:: able.filters + :members: + :member-order: bysource + :show-inheritance: + + +Advertising +^^^^^^^^^^^ + +.. automodule:: able.advertising + +Advertiser +"""""""""" +.. autoclass:: Advertiser + :members: + :member-order: bysource + + +Payload +""""""" +.. autoclass:: AdvertiseData + +.. autoclass:: DeviceName + :show-inheritance: +.. autoclass:: TXPowerLevel + :show-inheritance: +.. autoclass:: ServiceUUID + :show-inheritance: +.. autoclass:: ServiceData + :show-inheritance: +.. autoclass:: ManufacturerData + :show-inheritance: + +Constants +""""""""" + +.. autoclass:: Interval + :members: + :member-order: bysource + +.. autoclass:: TXPower + :members: + :member-order: bysource + +.. autoclass:: Status + :members: + :undoc-members: + :member-order: bysource diff --git a/libs/able/docs/conf.py b/libs/able/docs/conf.py new file mode 100644 index 0000000..28029ad --- /dev/null +++ b/libs/able/docs/conf.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +# +# ABLE documentation build configuration file, created by +# sphinx-quickstart on Sun Apr 16 23:19:55 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('..')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'ABLE' +copyright = u'2017, b3b' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.1' +# The full version, including alpha/beta/rc tags. +release = '0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'ABLEdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'ABLE.tex', u'ABLE Documentation', + u'b3b', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'able', u'ABLE Documentation', + [u'b3b'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'ABLE', u'ABLE Documentation', + u'b3b', 'ABLE', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# http://stackoverflow.com/questions/28366818/preserve-default-arguments-of-wrapped-decorated-python-function-in-sphinx-document +# Monkey-patch functools.wraps +import functools + +def no_op_wraps(func): + """Replaces functools.wraps in order to undo wrapping. + + Can be used to preserve the decorated function's signature + in the documentation generated by Sphinx. + + """ + def wrapper(decorator): + return func + return wrapper + +functools.wraps = no_op_wraps + + +# http://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules +# I get import errors on libraries that depend on C modules +from mock import MagicMock + +class Mock(MagicMock): + @classmethod + def __getattr__(cls, name): + return MagicMock() + +MOCK_MODULES = ['kivy', 'kivy.utils', 'kivy.clock', 'kivy.logger', + 'kivy.event'] +sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) +sys.modules['kivy.event'].EventDispatcher = object diff --git a/libs/able/docs/example.rst b/libs/able/docs/example.rst new file mode 100644 index 0000000..a408c21 --- /dev/null +++ b/libs/able/docs/example.rst @@ -0,0 +1,192 @@ +Usage Examples +============== + +Alert +----- + +.. literalinclude:: ./examples/alert.py + :language: python + +Full example code: `alert `_ + + +Change MTU +---------- +.. literalinclude:: ./examples/mtu.py + :language: python + + +Scan settings +------------- + +.. code-block:: python + + from able import BluetoothDispatcher + from able.scan_settings import ScanSettingsBuilder, ScanSettings + + # Use faster detection (more power usage) mode + settings = ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + BluetoothDispatcher().start_scan(settings=settings) + + +Scan filters +------------ + +.. code-block:: python + + from able import BluetoothDispatcher + from able.filters import ( + DeviceAddressFilter, + DeviceNameFilter, + ManufacturerDataFilter, + ServiceDataFilter, + ServiceUUIDFilter + ) + + ble = BluetoothDispatcher() + + # Start scanning with the condition that device has one of names: "Device1" or "Device2" + ble.start_scan(filters=[DeviceNameFilter("Device1"), DeviceNameFilter("Device2")]) + ble.stop_scan() + + # Start scanning with the condition that + # device advertises "180f" service and one of names: "Device1" or "Device2" + ble.start_scan(filters=[ + ServiceUUIDFilter('0000180f-0000-1000-8000-00805f9b34fb') & DeviceNameFilter("Device1"), + ServiceUUIDFilter('0000180f-0000-1000-8000-00805f9b34fb') & DeviceNameFilter("Device2") + ]) + + +Adapter state +------------- + +.. literalinclude:: ./examples/adapter_state_change.py + :language: python + + +Advertising +----------- + +Advertise with data and additional (scannable) data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: python + + from able import BluetoothDispatcher + from able.advertising import ( + Advertiser, + AdvertiseData, + ManufacturerData, + Interval, + ServiceUUID, + ServiceData, + TXPower, + ) + + advertiser = Advertiser( + ble=BluetoothDispatcher(), + data=AdvertiseData(ServiceUUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), + scan_data=AdvertiseData(ManufacturerData(id=0xAABB, data=b"some data")), + interval=Interval.MEDIUM, + tx_power=TXPower.MEDIUM, + ) + + advertiser.start() + + +Set and advertise device name +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from able import BluetoothDispatcher + from able.advertising import Advertiser, AdvertiseData, DeviceName + + ble = BluetoothDispatcher() + ble.name = "New test device name" + + # There must be a wait and check, it takes time for new name to take effect + print(f"New device name is set: {ble.name}") + + Advertiser( + ble=ble, + data=AdvertiseData(DeviceName()) + ) + + +Battery service data +^^^^^^^^^^^^^^^^^^^^ + +.. literalinclude:: ./examples/advertising_battery.py + :language: python + + +Use iBeacon advertising format +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + import uuid + from able import BluetoothDispatcher + from able.advertising import Advertiser, AdvertiseData, ManufacturerData + + + data = AdvertiseData( + ManufacturerData( + 0x4C, # Apple Manufacturer ID + bytes([ + 0x2, # SubType: Custom Manufacturer Data + 0x15 # Subtype lenth + ]) + + uuid.uuid4().bytes + # UUID of beacon + bytes([ + 0, 15, # Major value + 0, 1, # Minor value + 10 # RSSI, dBm at 1m + ])) + ) + + Advertiser(BluetoothDispatcher(), data).start() + + +Android Services +---------------- + +BLE devices scanning service +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**main.py** + +.. literalinclude:: ./examples/service_scan_main.py + :language: python + +**service.py** + +.. literalinclude:: ./examples/service_scan_service.py + :language: python + +Full example code: `service_scan `_ + + +Advertising service +^^^^^^^^^^^^^^^^^^^ + +**main.py** + +.. literalinclude:: ./examples/service_advertise_main.py + :language: python + +**service.py** + +.. literalinclude:: ./examples/service_advertise_service.py + :language: python + +Full example code: `service_advertise `_ + + +Connect to multiple devices +--------------------------- + +.. literalinclude:: ./examples/multi_devices/main.py + :language: python + +Full example code: `multi_devices `_ diff --git a/libs/able/docs/index.rst b/libs/able/docs/index.rst new file mode 100644 index 0000000..0c01475 --- /dev/null +++ b/libs/able/docs/index.rst @@ -0,0 +1,3 @@ +.. include:: ../README.rst +.. include:: api.rst +.. include:: example.rst diff --git a/libs/able/examples/adapter_state_change.py b/libs/able/examples/adapter_state_change.py new file mode 100644 index 0000000..0e97f6b --- /dev/null +++ b/libs/able/examples/adapter_state_change.py @@ -0,0 +1,27 @@ +"""Detect and log Bluetooth adapter state change.""" + +from typing import Optional + +from kivy.logger import Logger +from kivy.uix.widget import Widget + +from able import AdapterState, BluetoothDispatcher + + +class Dispatcher(BluetoothDispatcher): + def on_bluetooth_adapter_state_change(self, state: int): + Logger.info( + f"Bluetoth adapter state changed to {state} ('{AdapterState(state).name}')." + ) + if state == AdapterState.OFF: + Logger.info("Adapter state changed to OFF.") + + +class StateChangeApp(App): + def build(self): + Dispatcher() + return Widget() + + +if __name__ == "__main__": + StateChangeApp.run() diff --git a/libs/able/examples/advertising_battery.py b/libs/able/examples/advertising_battery.py new file mode 100644 index 0000000..970e622 --- /dev/null +++ b/libs/able/examples/advertising_battery.py @@ -0,0 +1,71 @@ +"""Advertise battery level, that degrades every second.""" +from kivy.app import App +from kivy.clock import Clock +from kivy.uix.label import Label + +from able import BluetoothDispatcher +from able import advertising + +# Standard fully-qualified UUID for the Battery Service +BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb" + + +class BatteryAdvertiser(advertising.Advertiser): + + def on_advertising_started(self, advertising_set, tx_power, status): + if status == advertising.Status.SUCCESS: + print("Advertising is started successfully") + else: + print(f"Advertising start error status: {status}") + + def on_advertising_stopped(self, advertising_set): + print("Advertising stopped") + + +class BatteryLabel(Label): + """Widget to control advertiser and show current battery level.""" + + def __init__(self): + self._level = 0 + super().__init__(text="Waiting for advertising to start...") + self.advertiser = BatteryAdvertiser( + ble=BluetoothDispatcher(), + data=self.construct_data(level=100), + interval=advertising.Interval.MIN + ) + self.advertiser.bind(on_advertising_started=self.on_started) # bind to start of advertising + self.advertiser.start() + + def on_started(self, advertiser, advertising_set, tx_power, status): + if status == advertising.Status.SUCCESS: + # Advertising is started - update battery level every second + self.clock = Clock.schedule_interval(self.update_level, 1) + + def update_level(self, dt): + level = self._level = (self._level - 1) % 101 + self.text = str(level) + + if level > 0: + # Set new advertising data + self.advertiser.data = self.construct_data(level) + else: + self.clock.cancel() + # Stop advertising + self.advertiser.stop() + + def construct_data(self, level): + return advertising.AdvertiseData( + advertising.DeviceName(), + advertising.TXPowerLevel(), + advertising.ServiceData(BATTERY_SERVICE_UUID, [level]) + ) + + +class BatteryApp(App): + + def build(self): + return BatteryLabel() + + +if __name__ == "__main__": + BatteryApp().run() diff --git a/libs/able/examples/alert/buildozer.spec b/libs/able/examples/alert/buildozer.spec new file mode 100644 index 0000000..43b4d7b --- /dev/null +++ b/libs/able/examples/alert/buildozer.spec @@ -0,0 +1,27 @@ +[app] +title = Alert Mi +version = 1.1 +package.name = alert_mi +package.domain = org.kivy +source.dir = . +source.include_exts = py,png,jpg,kv,atlas + +requirements = python3,kivy,android,able_recipe + +android.accept_sdk_license = True +android.permissions = + BLUETOOTH, + BLUETOOTH_ADMIN, + BLUETOOTH_SCAN, + BLUETOOTH_CONNECT, + BLUETOOTH_ADVERTISE, + ACCESS_FINE_LOCATION + +# android.api = 31 +# android.minapi = 31 + + +[buildozer] +warn_on_root = 1 +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 diff --git a/libs/able/examples/alert/error_message.kv b/libs/able/examples/alert/error_message.kv new file mode 100644 index 0000000..90edcf0 --- /dev/null +++ b/libs/able/examples/alert/error_message.kv @@ -0,0 +1,21 @@ +: + BoxLayout: + orientation: 'vertical' + padding: 10 + spacing: 20 + Label: + size_hint_y: None + font_size: '18sp' + height: '24sp' + text: 'Application has crashed, details: ' + ScrollView: + size_hint: 1, 1 + TextInput: + text: root.message + size_hint: 1, None + height: self.minimum_height + Button: + size_hint_y: None + height: '40sp' + text: 'OK, terminate' + on_press: root.dismiss() diff --git a/libs/able/examples/alert/error_message.py b/libs/able/examples/alert/error_message.py new file mode 100644 index 0000000..d6722a7 --- /dev/null +++ b/libs/able/examples/alert/error_message.py @@ -0,0 +1,39 @@ +import os +import traceback + +from kivy.base import ( + ExceptionHandler, + ExceptionManager, + stopTouchApp, +) +from kivy.properties import StringProperty +from kivy.uix.popup import Popup +from kivy.lang import Builder +from kivy.logger import Logger + + +Builder.load_file(os.path.join(os.path.dirname(__file__), 'error_message.kv')) + + +class ErrorMessageOnException(ExceptionHandler): + + def handle_exception(self, exception): + Logger.exception('Unhandled Exception catched') + message = ErrorMessage(message=traceback.format_exc()) + + def raise_exception(*ar2gs, **kwargs): + stopTouchApp() + raise Exception("Exit due to errors") + + message.bind(on_dismiss=raise_exception) + message.open() + return ExceptionManager.PASS + + +class ErrorMessage(Popup): + title = StringProperty('Bang!') + message = StringProperty('') + + +def install_exception_handler(): + ExceptionManager.add_handler(ErrorMessageOnException()) diff --git a/libs/able/examples/alert/main.py b/libs/able/examples/alert/main.py new file mode 100644 index 0000000..433673a --- /dev/null +++ b/libs/able/examples/alert/main.py @@ -0,0 +1,64 @@ +"""Turn the alert on Mi Band device +""" +from kivy.app import App +from kivy.uix.button import Button + +from able import BluetoothDispatcher, GATT_SUCCESS +from error_message import install_exception_handler + + +class BLE(BluetoothDispatcher): + device = alert_characteristic = None + + def start_alert(self, *args, **kwargs): + if self.alert_characteristic: # alert service is already discovered + self.alert(self.alert_characteristic) + elif self.device: # device is already founded during the scan + self.connect_gatt(self.device) # reconnect + else: + self.stop_scan() # stop previous scan + self.start_scan() # start a scan for devices + + def on_device(self, device, rssi, advertisement): + # some device is found during the scan + name = device.getName() + if name and name.startswith('MI'): # is a Mi Band device + self.device = device + self.stop_scan() + + def on_scan_completed(self): + if self.device: + self.connect_gatt(self.device) # connect to device + + def on_connection_state_change(self, status, state): + if status == GATT_SUCCESS and state: # connection established + self.discover_services() # discover what services a device offer + else: # disconnection or error + self.alert_characteristic = None + self.close_gatt() # close current connection + + def on_services(self, status, services): + # 0x2a06 is a standard code for "Alert Level" characteristic + # https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.alert_level.xml + self.alert_characteristic = services.search('2a06') + self.alert(self.alert_characteristic) + + def alert(self, characteristic): + self.write_characteristic(characteristic, [2]) # 2 is for "High Alert" + + +class AlertApp(App): + + def build(self): + self.ble = None + return Button(text='Press to Alert Mi', on_press=self.start_alert) + + def start_alert(self, *args, **kwargs): + if not self.ble: + self.ble = BLE() + self.ble.start_alert() + + +if __name__ == '__main__': + install_exception_handler() + AlertApp().run() diff --git a/libs/able/examples/mtu.py b/libs/able/examples/mtu.py new file mode 100644 index 0000000..83a8da9 --- /dev/null +++ b/libs/able/examples/mtu.py @@ -0,0 +1,52 @@ +"""Request MTU change, and write 100 bytes to a characteristic.""" +from kivy.app import App +from kivy.clock import Clock +from kivy.logger import Logger +from kivy.uix.widget import Widget + +from able import BluetoothDispatcher, GATT_SUCCESS + + +class BLESender(BluetoothDispatcher): + + def __init__(self): + super().__init__() + self.characteristic_to_write = None + Clock.schedule_once(self.connect, 0) + + def connect(self, _): + self.connect_by_device_address("FF:FF:FF:FF:FF:FF") + + def on_connection_state_change(self, status, state): + if status == GATT_SUCCESS and state: + self.discover_services() + + def on_services(self, status, services): + if status == GATT_SUCCESS: + self.characteristic_to_write = services.search("0d03") + # Need to request 100 + 3 extra bytes for ATT packet header + self.request_mtu(103) + + def on_mtu_changed(self, mtu, status): + if status == GATT_SUCCESS and mtu == 103: + Logger.info("MTU changed: now it is possible to send 100 bytes at once") + self.write_characteristic(self.characteristic_to_write, range(100)) + else: + Logger.error("MTU not changed: mtu=%d, status=%d", mtu, status) + + def on_characteristic_write(self, characteristic, status): + if status == GATT_SUCCESS: + Logger.info("Characteristic write succeed") + else: + Logger.error("Write status: %d", status) + + +class MTUApp(App): + + def build(self): + BLESender() + return Widget() + + +if __name__ == '__main__': + MTUApp().run() diff --git a/libs/able/examples/multi_devices/buildozer.spec b/libs/able/examples/multi_devices/buildozer.spec new file mode 100644 index 0000000..f75a488 --- /dev/null +++ b/libs/able/examples/multi_devices/buildozer.spec @@ -0,0 +1,27 @@ +[app] +title = Multiple BLE devices +version = 1.0 +package.name = multidevs +package.domain = test.able +source.dir = . +source.include_exts = py,png,jpg,kv,atlas + +requirements = python3,kivy,android,able_recipe + +android.accept_sdk_license = True +android.permissions = + BLUETOOTH, + BLUETOOTH_ADMIN, + BLUETOOTH_SCAN, + BLUETOOTH_CONNECT, + BLUETOOTH_ADVERTISE, + ACCESS_FINE_LOCATION + +# android.api = 31 +# android.minapi = 31 + + +[buildozer] +warn_on_root = 1 +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 diff --git a/libs/able/examples/multi_devices/main.py b/libs/able/examples/multi_devices/main.py new file mode 100644 index 0000000..99b10e9 --- /dev/null +++ b/libs/able/examples/multi_devices/main.py @@ -0,0 +1,96 @@ +"""Scan for devices with name "KivyBLETest", +connect and periodically read connected devices RSSI. + +Multiple `BluetoothDispatcher` objects are used: + one for the scanning process and one for every connected device. +""" +from able import GATT_SUCCESS, BluetoothDispatcher +from able.filters import DeviceNameFilter +from kivy.app import App +from kivy.clock import Clock +from kivy.logger import Logger +from kivy.uix.label import Label + + +class DeviceDispatcher(BluetoothDispatcher): + """Dispatcher to control a single BLE device.""" + + def __init__(self, device: "BluetoothDevice"): + super().__init__() + self._device = device + self._address: str = device.getAddress() + self._name: str = device.getName() or "" + + @property + def title(self) -> str: + return f"<{self._address}><{self._name}>" + + def on_connection_state_change(self, status: int, state: int): + if status == GATT_SUCCESS and state: + Logger.info(f"Device: {self.title} connected") + else: + Logger.info(f"Device: {self.title} disconnected. {status=}, {state=}") + self.close_gatt() + Clock.schedule_once(callback=lambda dt: self.reconnect(), timeout=15) + + def on_rssi_updated(self, rssi: int, status: int): + Logger.info(f"Device: {self.title} RSSI: {rssi}") + + def periodically_update_rssi(self): + """ + Clock callback to read + the signal strength indicator for a connected device. + """ + if self.gatt: # if device is connected + self.update_rssi() + + def reconnect(self): + Logger.info(f"Device: {self.title} try to reconnect ...") + self.connect_gatt(self._device) + + def start(self): + """Start connection to device.""" + if not self.gatt: + self.connect_gatt(self._device) + Clock.schedule_interval( + callback=lambda dt: self.periodically_update_rssi(), timeout=5 + ) + + +class ScannerDispatcher(BluetoothDispatcher): + """Dispatcher to control the scanning process.""" + + def __init__(self): + super().__init__() + # Stores connected devices addresses + self._devices: dict[str, DeviceDispatcher] = {} + + def on_scan_started(self, success: bool): + if success: + Logger.info("Scan: started") + else: + Logger.error("Scan: error on start") + + def on_scan_completed(self): + Logger.info("Scan: completed") + + def on_device(self, device, rssi, advertisement): + address = device.getAddress() + if address not in self._devices: + # Create dispatcher instance for a new device + dispatcher = DeviceDispatcher(device) + # Remember address, + # to avoid multiple dispatchers creation for this device + self._devices[address] = dispatcher + Logger.info(f"Scan: device <{address}> added") + dispatcher.start() + + +class MultiDevicesApp(App): + def build(self): + ScannerDispatcher().start_scan(filters=[DeviceNameFilter("KivyBLETest")]) + return Label(text=self.name) + + +if __name__ == "__main__": + MultiDevicesApp().run() diff --git a/libs/able/examples/service_advertise/buildozer.spec b/libs/able/examples/service_advertise/buildozer.spec new file mode 100644 index 0000000..a8340f3 --- /dev/null +++ b/libs/able/examples/service_advertise/buildozer.spec @@ -0,0 +1,27 @@ +[app] +title = BLE advertising service +version = 1.1 +package.name = advservice +package.domain = test.able +source.dir = . +source.include_exts = py,png,jpg,kv,atlas + +android.permissions = + FOREGROUND_SERVICE, + BLUETOOTH, + BLUETOOTH_ADMIN, + BLUETOOTH_CONNECT, + BLUETOOTH_ADVERTISE + +requirements = kivy==2.1.0,python3,able_recipe +services = Able:service.py:foreground + +android.accept_sdk_license = True + +# android.api = 31 +# android.minapi = 31 + +[buildozer] +warn_on_root = 1 +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 diff --git a/libs/able/examples/service_advertise/main.py b/libs/able/examples/service_advertise/main.py new file mode 100644 index 0000000..446158d --- /dev/null +++ b/libs/able/examples/service_advertise/main.py @@ -0,0 +1,56 @@ +"""Start advertising service.""" +from able import BluetoothDispatcher, Permission, require_bluetooth_enabled +from jnius import autoclass +from kivy.app import App +from kivy.lang import Builder + + +kv = """ +BoxLayout: + Button: + text: 'Start service' + on_press: app.ble_dispatcher.start_service() + Button: + text: 'Stop service' + on_press: app.ble_dispatcher.stop_service() +""" + + +class Dispatcher(BluetoothDispatcher): + @property + def service(self): + return autoclass("test.able.advservice.ServiceAble") + + @property + def activity(self): + return autoclass("org.kivy.android.PythonActivity").mActivity + + # Need to turn on the adapter, before service is started + @require_bluetooth_enabled + def start_service(self): + self.service.start( + self.activity, + # Pass UUID to advertise + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + ) + App.get_running_app().stop() # Can close the app, service will continue running + + def stop_service(self): + self.service.stop(self.activity) + + +class ServiceApp(App): + def build(self): + self.ble_dispatcher = Dispatcher( + # This app does not use device scanning, + # so the list of required permissions can be reduced + runtime_permissions=[ + Permission.BLUETOOTH_CONNECT, + Permission.BLUETOOTH_ADVERTISE, + ] + ) + return Builder.load_string(kv) + + +if __name__ == "__main__": + ServiceApp().run() diff --git a/libs/able/examples/service_advertise/service.py b/libs/able/examples/service_advertise/service.py new file mode 100644 index 0000000..69edc81 --- /dev/null +++ b/libs/able/examples/service_advertise/service.py @@ -0,0 +1,28 @@ +"""Service to advertise data, while not stopped.""" +import time +from os import environ + +from able import BluetoothDispatcher +from able.advertising import ( + Advertiser, + AdvertiseData, + ServiceUUID, +) + + +def main(): + uuid = environ.get( + "PYTHON_SERVICE_ARGUMENT", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + ) + advertiser = Advertiser( + ble=BluetoothDispatcher(), + data=AdvertiseData(ServiceUUID(uuid)), + ) + advertiser.start() + while True: + time.sleep(0xDEAD) + + +if __name__ == "__main__": + main() diff --git a/libs/able/examples/service_scan/buildozer.spec b/libs/able/examples/service_scan/buildozer.spec new file mode 100644 index 0000000..54407b3 --- /dev/null +++ b/libs/able/examples/service_scan/buildozer.spec @@ -0,0 +1,29 @@ +[app] +title = BLE scan dev service +version = 1.1 +package.name = scanservice +package.domain = test.able +source.dir = . +source.include_exts = py,png,jpg,kv,atlas + +android.permissions = + FOREGROUND_SERVICE, + BLUETOOTH, + BLUETOOTH_ADMIN, + BLUETOOTH_SCAN, + BLUETOOTH_CONNECT, + BLUETOOTH_ADVERTISE, + ACCESS_FINE_LOCATION + +requirements = kivy==2.1.0,python3,able_recipe +services = Able:service.py:foreground + +android.accept_sdk_license = True + +# android.api = 31 +# android.minapi = 31 + +[buildozer] +warn_on_root = 1 +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 diff --git a/libs/able/examples/service_scan/main.py b/libs/able/examples/service_scan/main.py new file mode 100644 index 0000000..ce1f8a2 --- /dev/null +++ b/libs/able/examples/service_scan/main.py @@ -0,0 +1,48 @@ +"""Start BLE devices scaning service.""" +from able import ( + BluetoothDispatcher, + require_bluetooth_enabled, +) +from jnius import autoclass +from kivy.app import App +from kivy.lang import Builder + + +kv = """ +BoxLayout: + Button: + text: 'Start service' + on_press: app.ble_dispatcher.start_service() + Button: + text: 'Stop service' + on_press: app.ble_dispatcher.stop_service() +""" + + +class Dispatcher(BluetoothDispatcher): + @property + def service(self): + return autoclass("test.able.scanservice.ServiceAble") + + @property + def activity(self): + return autoclass("org.kivy.android.PythonActivity").mActivity + + # Need to turn on the adapter and obtain permissions, before service is started + @require_bluetooth_enabled + def start_service(self): + self.service.start(self.activity, "") + App.get_running_app().stop() # Can close the app, service will continue to run + + def stop_service(self): + self.service.stop(self.activity) + + +class ServiceApp(App): + def build(self): + self.ble_dispatcher = Dispatcher() + return Builder.load_string(kv) + + +if __name__ == "__main__": + ServiceApp().run() diff --git a/libs/able/examples/service_scan/service.py b/libs/able/examples/service_scan/service.py new file mode 100644 index 0000000..74e393e --- /dev/null +++ b/libs/able/examples/service_scan/service.py @@ -0,0 +1,27 @@ +"""Service to run BLE scan for 60 seconds, +and log each `on_device` event. +""" +import time + +from able import BluetoothDispatcher +from kivy.logger import Logger + + +class BLE(BluetoothDispatcher): + def on_device(self, device, rssi, advertisement): + title = device.getName() or device.getAddress() + Logger.info("BLE Device found: %s", title) + + def on_error(self, msg): + Logger.error("BLE Error %s", msg) + + +def main(): + ble = BLE() + ble.start_scan() + time.sleep(60) + ble.stop_scan() + + +if __name__ == "__main__": + main() diff --git a/libs/able/recipes/__init__.py b/libs/able/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/able/recipes/able_recipe/__init__.py b/libs/able/recipes/able_recipe/__init__.py new file mode 100644 index 0000000..ac4d546 --- /dev/null +++ b/libs/able/recipes/able_recipe/__init__.py @@ -0,0 +1,34 @@ +""" +Android Bluetooth Low Energy +""" +from pythonforandroid.recipe import PythonRecipe +from pythonforandroid.toolchain import current_directory, info, shprint +import sh +from os.path import join + + +class AbleRecipe(PythonRecipe): + name = 'able_recipe' + depends = ['python3', 'setuptools', 'android'] + call_hostpython_via_targetpython = False + install_in_hostpython = True + + def prepare_build_dir(self, arch): + build_dir = self.get_build_dir(arch) + assert build_dir.endswith(self.name) + shprint(sh.rm, '-rf', build_dir) + shprint(sh.mkdir, build_dir) + + for filename in ('../../able', 'setup.py'): + shprint(sh.cp, '-a', join(self.get_recipe_dir(), filename), + build_dir) + + def postbuild_arch(self, arch): + super(AbleRecipe, self).postbuild_arch(arch) + info('Copying able java class to classes build dir') + with current_directory(self.get_build_dir(arch.arch)): + shprint(sh.cp, '-a', join('able', 'src', 'org'), + self.ctx.javaclass_dir) + + +recipe = AbleRecipe() diff --git a/libs/able/recipes/able_recipe/setup.py b/libs/able/recipes/able_recipe/setup.py new file mode 100644 index 0000000..c2ca462 --- /dev/null +++ b/libs/able/recipes/able_recipe/setup.py @@ -0,0 +1,9 @@ +from setuptools import setup + +setup( + name='able', + version='0.0.0', + packages=['able', 'able.android'], + description='Bluetooth Low Energy for Android', + license='MIT', +) diff --git a/libs/able/setup.py b/libs/able/setup.py new file mode 100644 index 0000000..9ed545b --- /dev/null +++ b/libs/able/setup.py @@ -0,0 +1,135 @@ +import os +import re +import shutil +from pathlib import Path +from setuptools import setup +from setuptools.command.install import install + + +main_ns = {} +with Path("able/version.py").open() as ver_file: + exec(ver_file.read(), main_ns) + +with Path("README.rst").open() as readme_file: + long_description = readme_file.read() + + +class PathParser: + @property + def javaclass_dir(self): + path = self.build_dir / "javaclasses" + if not path.exists(): + raise Exception( + "Java classes directory is not found. " + "Please report issue to: https://github.com/b3b/able/issues" + ) + path = path / self.distribution_name + print(f"Java classes directory found: '{path}'.") + path.mkdir(parents=True, exist_ok=True) + return path + + @property + def distribution_name(self): + path = self.python_path + while path.parent.name != "python-installs": + if len(path.parts) <= 1: + raise Exception( + "Distribution name is not found. " + "Please report issue to: https://github.com/b3b/able/issues" + ) + path = path.parent + print(f"Distribution name found: '{path.name}'.") + return path.name + + @property + def build_dir(self): + return self.python_installs_dir.parent + + @property + def python_installs_dir(self): + path = self.python_path.parent + while path.name != "python-installs": + if len(path.parts) <= 1: + raise Exception( + "Python installs directory is not found. " + "Please report issue to: https://github.com/b3b/able/issues" + ) + path = path.parent + return path + + @property + def python_path(self): + cppflags = os.environ["CPPFLAGS"] + print(f"Searching for Python install directory in CPPFLAGS: '{cppflags}'") + match = re.search(r"-I(/[^\s]+/build/python-installs/[^/\s]+/)", cppflags) + if not match: + raise Exception("Can't find Python install directory.") + found_path = Path(match.group(1)) + print("FOUND INSTALL DIRECTORY: "+found_path) + return found_path + + +class InstallRecipe(install): + """Command to install `able` recipe, + copies Java files to distribution `javaclass` directory.""" + + def run(self): + if False and "ANDROIDAPI" not in os.environ: + raise Exception( + "This recipe should not be installed directly, " + "only with the buildozer tool." + ) + + # Find Java classes target directory from the environment + javaclass_dir = str(PathParser().javaclass_dir) + + for java_file in ( + "able/src/org/able/BLE.java", + "able/src/org/able/BLEAdvertiser.java", + "able/src/org/able/PythonBluetooth.java", + "able/src/org/able/PythonBluetoothAdvertiser.java", + ): + shutil.copy(java_file, javaclass_dir) + + install.run(self) + + +setup( + name="able_recipe", + version=main_ns["__version__"], + packages=["able", "able.android"], + description="Bluetooth Low Energy for Android", + long_description=long_description, + long_description_content_type="text/x-rst", + author="b3b", + author_email="ash.b3b@gmail.com", + install_requires=[], + url="https://github.com/b3b/able", + project_urls={ + "Changelog": "https://github.com/b3b/able/blob/master/CHANGELOG.rst", + }, + # https://pypi.org/classifiers/ + classifiers=[ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Operating System :: Android", + "Topic :: System :: Networking", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ], + keywords="android ble bluetooth kivy", + license="MIT", + zip_safe=False, + cmdclass={ + "install": InstallRecipe, + }, + options={ + "bdist_wheel": { + # Changing the wheel name + # to avoid installing a package from cache. + "plat_name": "unused-nocache", + }, + }, +) diff --git a/libs/able/testapps/bletest/.gitignore b/libs/able/testapps/bletest/.gitignore new file mode 100644 index 0000000..254defd --- /dev/null +++ b/libs/able/testapps/bletest/.gitignore @@ -0,0 +1 @@ +server diff --git a/libs/able/testapps/bletest/bletestapp.kv b/libs/able/testapps/bletest/bletestapp.kv new file mode 100644 index 0000000..bb9cab1 --- /dev/null +++ b/libs/able/testapps/bletest/bletestapp.kv @@ -0,0 +1,193 @@ +#:kivy 1.1.0 +#: import Factory kivy.factory.Factory +#: import findall re.findall + +: + padding_left: '4sp' + halign: 'left' + text_size: self.size + valign: 'middle' + +: + padding_left: '4sp' + halign: 'left' + text_size: self.size + valign: 'middle' + +: + title: 'Connect by MAC address' + size_hint: None, None + size: '400sp', '120sp' + BoxLayout: + orientation: 'vertical' + pos: self.pos + size: root.size + + TextInput: + size_hint_y: .5 + hint_text: 'Device address' + input_filter: lambda value, _ : ''.join(findall('[0-9a-fA-F:]+', value)).upper() + multiline: False + text: app.device_address + on_text: app.device_address = self.text + + BoxLayout: + orientation: 'horizontal' + size_hint_y: .5 + Button: + text: 'Connect' + on_press: root.dismiss(), app.connect_by_mac_address() + Button: + text: 'Cancel' + on_press: root.dismiss() + +: + padding: '10sp' + BoxLayout: + orientation: 'horizontal' + GridLayout: + cols: 2 + padding: '0sp' + spacing: '0sp' + orientation: 'lr-tb' + + Caption: + text: 'Adapter:' + Value: + text: app.adapter_state + + Caption: + text: 'State:' + Value: + text: app.state + halign: 'left' + valign: 'middle' + text_size: self.size + + Caption: + text: 'Read test:' + Value: + text: app.test_string + + Caption: + text: 'Notifications count:' + Value: + text: app.notification_value + + Caption: + text: 'N packets sended:' + Value: + text: app.increment_count_value + + Caption: + text: 'N packets delivered:' + Value: + text: app.counter_value + + Caption: + text: 'Total transmission time:' + Value: + text: app.counter_total_time + + BoxLayout: + spacing: '20sp' + orientation: 'vertical' + + BoxLayout: + orientation: 'horizontal' + size_hint_y: .3 + + Button: + text: 'Scan and connect' + on_press: app.start_scan() + + Button: + text: 'Connect by MAC address' + on_press: Factory.ConnectByMACDialog().open() + + BoxLayout: + id: queue_box + orientation: 'vertical' + size_hint_y: .15 + + BoxLayout: + orientation: 'horizontal' + Caption: + text: 'Enable GATT autoconnect:' + CheckBox: + id: timeout_checkbox + active: app.autoconnect + on_active: app.autoconnect = self.active + + BoxLayout: + orientation: 'horizontal' + size_hint_y: .2 + spacing: 10 + Button: + disabled: app.state != 'connected' + text: 'Read RSSI' + on_press: app.read_rssi() + Caption: + text: 'RSSI Value:' + Value: + text: app.rssi + + ToggleButton: + disabled: app.state != 'connected' + text: "Enable notifications" + size_hint_y: .2 + on_state: app.enable_notifications(self.state == 'down') + BoxLayout: + id: queue_box + orientation: 'vertical' + disabled: app.state != 'connected' + + BoxLayout: + orientation: 'horizontal' + Caption: + text: 'Enable BLE queue timeout:' + CheckBox: + id: timeout_checkbox + active: app.queue_timeout_enabled + on_active: app.queue_timeout_enabled = self.active + BoxLayout: + orientation: 'horizontal' + Caption: + text: 'BLE queue timeout (ms):' + TextInput: + disabled: queue_box.disabled or not timeout_checkbox.active + input_filter: 'int' + multiline: False + text: app.queue_timeout + on_text: app.queue_timeout = self.text + BoxLayout: + Button: + text: 'Apply queue settings' + on_press: app.set_queue_settings() + + BoxLayout: + disabled: app.state != 'connected' + orientation: 'vertical' + BoxLayout: + orientation: 'horizontal' + Caption: + text: 'Transmission interval (ms):' + TextInput: + input_filter: 'int' + multiline: False + text: app.incremental_interval + on_text: app.incremental_interval = self.text + BoxLayout: + orientation: 'horizontal' + Caption: + text: 'Packet count limit:' + TextInput: + input_filter: 'int' + multiline: False + text: app.counter_max + on_text: app.counter_max = self.text + padding_bottom: '100sp' + ToggleButton: + width: self.texture_size[0] + 50 + text: "Enable transmission" + on_state: app.enable_counter(self.state == 'down') diff --git a/libs/able/testapps/bletest/buildozer.spec b/libs/able/testapps/bletest/buildozer.spec new file mode 100644 index 0000000..102e39a --- /dev/null +++ b/libs/able/testapps/bletest/buildozer.spec @@ -0,0 +1,17 @@ +[app] +title = BLE functions test +version = 1.0 +package.name = kivy_ble_test +package.domain = org.kivy +source.dir = . +source.include_exts = py,png,jpg,kv,atlas +android.permissions = BLUETOOTH, BLUETOOTH_ADMIN, ACCESS_FINE_LOCATION +requirements = python3,kivy,android,able_recipe + +# (str) Android's logcat filters to use +android.logcat_filters = *:S python:D + +[buildozer] +warn_on_root = 1 +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 2 diff --git a/libs/able/testapps/bletest/main.py b/libs/able/testapps/bletest/main.py new file mode 100644 index 0000000..9d27429 --- /dev/null +++ b/libs/able/testapps/bletest/main.py @@ -0,0 +1,245 @@ +"""Connect to "KivyBLETest" server and test various BLE functions +""" +import time + +from able import AdapterState, GATT_SUCCESS, BluetoothDispatcher +from kivy.app import App +from kivy.clock import Clock +from kivy.config import Config +from kivy.properties import BooleanProperty, StringProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.storage.jsonstore import JsonStore + +Config.set('kivy', 'log_level', 'debug') +Config.set('kivy', 'log_enable', '1') + + +class MainLayout(BoxLayout): + pass + + +class BLETestApp(App): + ble = BluetoothDispatcher() + adapter_state = StringProperty('') + state = StringProperty('') + test_string = StringProperty('') + rssi = StringProperty('') + notification_value = StringProperty('') + counter_value = StringProperty('') + increment_count_value = StringProperty('') + incremental_interval = StringProperty('100') + counter_max = StringProperty('128') + counter_value = StringProperty('') + counter_state = StringProperty('') + counter_total_time = StringProperty('') + queue_timeout_enabled = BooleanProperty(True) + queue_timeout = StringProperty('1000') + device_name = StringProperty('KivyBLETest') + device_address = StringProperty('') + autoconnect = BooleanProperty(False) + + store = JsonStore('bletestapp.json') + + uids = { + 'string': '0d01', + 'counter_reset': '0d02', + 'counter_increment': '0d03', + 'counter_read': '0d04', + 'notifications': '0d05' + } + + def build(self): + if self.store.exists('device'): + self.device_address = self.store.get('device')['address'] + else: + self.device_address = '' + return MainLayout() + + def on_pause(self): + return True + + def on_resume(self): + pass + + def init(self): + self.set_queue_settings() + self.ble.bind(on_device=self.on_device) + self.ble.bind(on_scan_started=self.on_scan_started) + self.ble.bind(on_scan_completed=self.on_scan_completed) + self.ble.bind(on_bluetooth_adapter_state_change=self.on_bluetooth_adapter_state_change) + self.ble.bind( + on_connection_state_change=self.on_connection_state_change) + self.ble.bind(on_services=self.on_services) + self.ble.bind(on_characteristic_read=self.on_characteristic_read) + self.ble.bind(on_characteristic_changed=self.on_characteristic_changed) + self.ble.bind(on_rssi_updated=self.on_rssi_updated) + + def start_scan(self): + if not self.state: + self.init() + self.state = 'scan_start' + self.ble.close_gatt() + self.ble.start_scan() + + def connect_by_mac_address(self): + self.store.put('device', address=self.device_address) + if not self.state: + self.init() + self.state = 'try_connect' + self.ble.close_gatt() + try: + self.ble.connect_by_device_address( + self.device_address, + autoconnect=self.autoconnect, + ) + except ValueError as exc: + self.state = str(exc) + + def on_scan_started(self, ble, success): + self.state = 'scan' if success else 'scan_error' + + def on_device(self, ble, device, rssi, advertisement): + if self.state != 'scan': + return + if device.getName() == self.device_name: + self.device = device + self.state = 'found' + self.ble.stop_scan() + + def on_scan_completed(self, ble): + if self.device: + self.ble.connect_gatt( + self.device, + autoconnect=self.autoconnect, + ) + + def on_connection_state_change(self, ble, status, state): + if status == GATT_SUCCESS: + if state: + self.ble.discover_services() + else: + self.state = 'disconnected' + else: + self.state = 'connection_error' + + def on_services(self, ble, status, services): + if status != GATT_SUCCESS: + self.state = 'services_error' + return + self.state = 'connected' + self.services = services + self.read_test_string(ble) + self.characteristics = { + 'counter_increment': self.services.search( + self.uids['counter_increment']), + 'counter_reset': self.services.search( + self.uids['counter_reset']), + } + + def on_bluetooth_adapter_state_change(self, ble, state): + self.adapter_state = AdapterState(state).name + + def read_rssi(self): + self.rssi = '...' + result = self.ble.update_rssi() + + def on_rssi_updated(self, ble, rssi, status): + self.rssi = str(rssi) if status == GATT_SUCCESS else f"Bad status: {status}" + + def read_test_string(self, ble): + characteristic = self.services.search(self.uids['string']) + if characteristic: + ble.read_characteristic(characteristic) + else: + self.test_string = 'not found' + + def read_remote_counter(self): + characteristic = self.services.search(self.uids['counter_read']) + if characteristic: + self.ble.read_characteristic(characteristic) + else: + self.counter_value = 'error' + + def enable_notifications(self, enable): + if enable: + self.notification_value = '0' + characteristic = self.services.search(self.uids['notifications']) + if characteristic: + self.ble.enable_notifications(characteristic, enable) + else: + self.notification_value = 'error' + + def enable_counter(self, enable): + if enable: + self.counter_state = 'init' + interval = int(self.incremental_interval) * .001 + Clock.schedule_interval(self.counter_next, interval) + else: + Clock.unschedule(self.counter_next) + if self.counter_state != 'stop': + self.counter_state = 'stop' + self.read_remote_counter() + + def counter_next(self, dt): + if self.counter_state == 'init': + self.counter_started_time = time.time() + self.counter_total_time = '' + self.reset_remote_counter() + self.increment_remote_counter() + elif self.counter_state == 'enabled': + if int(self.increment_count_value) < int(self.counter_max): + self.increment_remote_counter() + else: + self.enable_counter(False) + + def reset_remote_counter(self): + self.increment_count_value = '0' + self.counter_value = '' + self.ble.write_characteristic(self.characteristics['counter_reset'], []) + self.counter_state = 'enabled' + + def on_characteristic_read(self, ble, characteristic, status): + uuid = characteristic.getUuid().toString() + if self.uids['string'] in uuid: + self.update_string_value(characteristic, status) + elif self.uids['counter_read'] in uuid: + self.counter_total_time = str( + time.time() - self.counter_started_time) + self.update_counter_value(characteristic, status) + + def update_string_value(self, characteristic, status): + result = 'ERROR' + if status == GATT_SUCCESS: + value = characteristic.getStringValue(0) + if value == 'test': + result = 'OK' + self.test_string = result + + def increment_remote_counter(self): + characteristic = self.characteristics['counter_increment'] + self.ble.write_characteristic(characteristic, []) + prev_value = int(self.increment_count_value) + self.increment_count_value = str(prev_value + 1) + + def update_counter_value(self, characteristic, status): + if status == GATT_SUCCESS: + self.counter_value = characteristic.getStringValue(0) + else: + self.counter_value = 'ERROR' + + def set_queue_settings(self): + self.ble.set_queue_timeout(None if not self.queue_timeout_enabled + else int(self.queue_timeout) * .001) + + def on_characteristic_changed(self, ble, characteristic): + uuid = characteristic.getUuid().toString() + if self.uids['notifications'] in uuid: + prev_value = self.notification_value + value = int(characteristic.getStringValue(0)) + if (prev_value == 'error') or (value != int(prev_value) + 1): + value = 'error' + self.notification_value = str(value) + + +if __name__ == '__main__': + BLETestApp().run() diff --git a/libs/able/testapps/bletest/server.go b/libs/able/testapps/bletest/server.go new file mode 100644 index 0000000..fcb9ced --- /dev/null +++ b/libs/able/testapps/bletest/server.go @@ -0,0 +1,95 @@ +// +build + +// based on https://github.com/paypal/gatt/blob/master/examples/server.go + +package main + +import ( + "fmt" + "log" + "time" + "github.com/paypal/gatt" + "github.com/paypal/gatt/linux/cmd" +) + + +var DefaultServerOptions = []gatt.Option{ + gatt.LnxMaxConnections(1), + gatt.LnxDeviceID(-1, false), + gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{ + AdvertisingIntervalMin: 0x04ff, + AdvertisingIntervalMax: 0x04ff, + AdvertisingChannelMap: 0x7, + }), +} + + +func NewTestPythonService() *gatt.Service { + n := 0 + s := gatt.NewService(gatt.MustParseUUID("16fe0d00-c111-11e3-b8c8-0002a5d5c51b")) + + s.AddCharacteristic(gatt.MustParseUUID("16fe0d01-c111-11e3-b8c8-0002a5d5c51b")).HandleReadFunc( + func(rsp gatt.ResponseWriter, req *gatt.ReadRequest) { + n = 0 + log.Println("Echo") + fmt.Fprintf(rsp, "test") + }) + s.AddCharacteristic(gatt.MustParseUUID("16fe0d02-c111-11e3-b8c8-0002a5d5c51b")).HandleWriteFunc( + func(r gatt.Request, data []byte) (status byte) { + n = 0 + log.Println("Reset counter") + return gatt.StatusSuccess + }) + s.AddCharacteristic(gatt.MustParseUUID("16fe0d03-c111-11e3-b8c8-0002a5d5c51b")).HandleWriteFunc( + func(r gatt.Request, data []byte) (status byte) { + n++ + log.Println("Increment counter") + return gatt.StatusSuccess + }) + s.AddCharacteristic(gatt.MustParseUUID("16fe0d04-c111-11e3-b8c8-0002a5d5c51b")).HandleReadFunc( + func(rsp gatt.ResponseWriter, req *gatt.ReadRequest) { + log.Println("Response counter: ", n) + fmt.Fprintf(rsp, "%d", n) + }) + + s.AddCharacteristic(gatt.MustParseUUID("16fe0d05-c111-11e3-b8c8-0002a5d5c51b")).HandleNotifyFunc( + func(r gatt.Request, n gatt.Notifier) { + log.Println("Notifications enabled") + cnt := 1 + for !n.Done() { + fmt.Fprintf(n, "%d", cnt) + cnt++ + time.Sleep(100 * time.Millisecond) + } + log.Println("Notifications disabled") + }) + + return s +} + + +func main() { + d, err := gatt.NewDevice(DefaultServerOptions...) + if err != nil { + log.Fatalf("Failed to open device, err: %s", err) + } + + d.Handle( + gatt.CentralConnected(func(c gatt.Central) { fmt.Println("Connect: ", c.ID()) }), + gatt.CentralDisconnected(func(c gatt.Central) { fmt.Println("Disconnect: ", c.ID()) }), + ) + + onStateChanged := func(d gatt.Device, s gatt.State) { + fmt.Printf("State: %s\n", s) + switch s { + case gatt.StatePoweredOn: + s1 := NewTestPythonService() + d.AddService(s1) + d.AdvertiseNameAndServices("KivyBLETest", []gatt.UUID{s1.UUID()}) + default: + } + } + + d.Init(onStateChanged) + select {} +} diff --git a/libs/able/tests/__init__.py b/libs/able/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/able/tests/notebooks/.gitignore b/libs/able/tests/notebooks/.gitignore new file mode 100644 index 0000000..51b09fe --- /dev/null +++ b/libs/able/tests/notebooks/.gitignore @@ -0,0 +1,4 @@ +there.env +.ipynb_checkpoints/ +*.asciidoc +*.ipynb diff --git a/libs/able/tests/notebooks/init.md b/libs/able/tests/notebooks/init.md new file mode 100644 index 0000000..d25939a --- /dev/null +++ b/libs/able/tests/notebooks/init.md @@ -0,0 +1,43 @@ +--- +jupyter: + jupytext: + formats: ipynb,md + text_representation: + extension: .md + format_name: markdown + format_version: '1.3' + jupytext_version: 1.11.2 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +```python +from time import sleep +%load_ext pythonhere +%connect-there +``` + +```python +%%there +from dataclasses import dataclass, field +from typing import List + +from jnius import autoclass, cast + +from able.android.dispatcher import ( + BluetoothDispatcher +) + +from able.scan_settings import ( + ScanSettings, + ScanSettingsBuilder +) + +@dataclass +class Results: + started: bool = None + completed: bool = None + devices: List = field(default_factory=lambda: []) +``` diff --git a/libs/able/tests/notebooks/run b/libs/able/tests/notebooks/run new file mode 100755 index 0000000..19d701a --- /dev/null +++ b/libs/able/tests/notebooks/run @@ -0,0 +1,22 @@ +#!/bin/bash +set -ex + +name="$1" +command="${2}" +command="${command:=test}" + + +cat "${name}.md" | + jupytext --execute --to ipynb | + jupyter nbconvert --stdin --no-input --to asciidoc --output "${name}" + +cat "${name}".asciidoc + +if [ "${command}" = "test" ]; then + diff "${name}.asciidoc" "${name}.expected" +elif [ "${command}" = "record" ]; then + cp "${name}".asciidoc "${name}".expected +else + echo "Unknown command: ${command}" + exit 1 +fi diff --git a/libs/able/tests/notebooks/run_all_tests b/libs/able/tests/notebooks/run_all_tests new file mode 100755 index 0000000..7906d87 --- /dev/null +++ b/libs/able/tests/notebooks/run_all_tests @@ -0,0 +1,4 @@ +#!/bin/bash +set -e + +for name in test_*.md; do ./run "${name%%.*}"; done diff --git a/libs/able/tests/notebooks/test_basic.expected b/libs/able/tests/notebooks/test_basic.expected new file mode 100644 index 0000000..bd4bd66 --- /dev/null +++ b/libs/able/tests/notebooks/test_basic.expected @@ -0,0 +1,26 @@ +[[setup]] += Setup + +[[run-ble-devices-scan]] += Run BLE devices scan + + +---- +Started: None Completed: None +---- + +[[check-that-scan-started-and-completed]] += Check that scan started and completed + + +---- +Started: 1 Completed: 1 +---- + +[[check-that-testing-device-was-discovered]] += Check that testing device was discovered + + +---- +True +---- diff --git a/libs/able/tests/notebooks/test_basic.md b/libs/able/tests/notebooks/test_basic.md new file mode 100644 index 0000000..4e16ed9 --- /dev/null +++ b/libs/able/tests/notebooks/test_basic.md @@ -0,0 +1,74 @@ +--- +jupyter: + jupytext: + formats: ipynb,md + text_representation: + extension: .md + format_name: markdown + format_version: '1.3' + jupytext_version: 1.11.2 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Setup + +```python +%run init.ipynb +``` + +```python +%%there +class BLE(BluetoothDispatcher): + + def on_scan_started(self, success): + results.started = success + + def on_scan_completed(self): + results.completed = 1 + + def on_device(self, device, rssi, advertisement): + results.devices.append(device) + +ble = BLE() +``` + +# Run BLE devices scan + +```python +%%there +results = Results() +print(f"Started: {results.started} Completed: {results.completed}") +ble.start_scan() +``` + +```python +sleep(10) +``` + +```python +%%there +ble.stop_scan() +``` + +```python +sleep(2) +``` + +# Check that scan started and completed + +```python +%%there +print(f"Started: {results.started} Completed: {results.completed}") +``` + +# Check that testing device was discovered + +```python +%%there +print( + "KivyBLETest" in [dev.getName() for dev in results.devices] +) +``` diff --git a/libs/able/tests/notebooks/test_scan_filters.expected b/libs/able/tests/notebooks/test_scan_filters.expected new file mode 100644 index 0000000..165be43 --- /dev/null +++ b/libs/able/tests/notebooks/test_scan_filters.expected @@ -0,0 +1,72 @@ +[[setup]] += Setup + +[[test-device-is-found-with-scan-filters-set]] += Test device is found with scan filters set + + +---- +{'KivyBLETest'} +---- + +[[test-device-is-not-found-filtered-out-by-name]] += Test device is not found: filtered out by name + + +---- +[] +---- + +[[test-scan-filter-mathes]] += Test scan filter mathes + + +---- +EmptyFilter() True +EmptyFilter() True +EmptyFilter() True +---- + + +---- +DeviceAddressFilter(address='AA:AA:AA:AA:AA:AA') True +DeviceAddressFilter(address='AA:AA:AA:AA:AA:AB') False +AA is not a valid Bluetooth address +---- + + +---- +DeviceNameFilter(name='KivyBLETest') True +DeviceNameFilter(name='KivyBLETes') False +---- + + +---- +ManufacturerDataFilter(id=76, data=[], mask=None) False +ManufacturerDataFilter(id=76, data=[], mask=None) True +ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 229], mask=None) True +ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=None) False +ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=[255, 255, 255, 255, 255, 255, 0]) True +ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=[255, 255, 255, 255, 255, 255, 255]) False +ManufacturerDataFilter(id=76, data=[2, 0, 141, 166, 131], mask=[255, 0, 255, 255, 255]) True +ManufacturerDataFilter(id=76, data=b'\x02\x15', mask=None) True +ManufacturerDataFilter(id=76, data=b'\x02\x16', mask=None) False +---- + + +---- +ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[], mask=None) True +ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fc', data=[], mask=None) False +ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[34], mask=None) True +ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=None) False +ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=[240]) True +ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=[15]) False +---- + + +---- +ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51B', mask=None) True +ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask=None) False +ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask='ffffffff-ffff-ffff-ffff-ffffffffffff') False +ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask='ffffffff-ffff-ffff-ffff-fffffffffff0') True +---- diff --git a/libs/able/tests/notebooks/test_scan_filters.md b/libs/able/tests/notebooks/test_scan_filters.md new file mode 100644 index 0000000..177a6c0 --- /dev/null +++ b/libs/able/tests/notebooks/test_scan_filters.md @@ -0,0 +1,222 @@ +--- +jupyter: + jupytext: + formats: ipynb,md + text_representation: + extension: .md + format_name: markdown + format_version: '1.3' + jupytext_version: 1.11.2 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Setup + +```python +%run init.ipynb +``` + +```python +%%there +from able.filters import * + +class BLE(BluetoothDispatcher): + + def on_scan_started(self, success): + results.started = success + + def on_scan_completed(self): + results.completed = 1 + + def on_device(self, device, rssi, advertisement): + results.devices.append(device) + +ble = BLE() +``` + +```python +%%there +BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice") +ScanResult = autoclass("android.bluetooth.le.ScanResult") +ScanRecord = autoclass("android.bluetooth.le.ScanRecord") +Parcel = autoclass("android/os/Parcel") +ParcelUuid = autoclass('android.os.ParcelUuid') + +def filter_matches(f, scan_result): + print(f, f.build().matches(scan_result)) + +def mock_device(): + """Return BluetoothDevice instance with address=AA:AA:AA:AA:AA:AA""" + device_data = [17, 0, 0, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65] + p = Parcel.obtain() + p.unmarshall(device_data, 0, len(device_data)) + p.setDataPosition(0) + return BluetoothDevice.CREATOR.createFromParcel(p) + + +def mock_scan_result(record): + return ScanResult(mock_device(), ScanRecord.parseFromBytes(record), -33, 1633954394000) + +def mock_test_app_scan_result(): + return mock_scan_result( + [2, 1, 6, 17, 6, 27, 197, 213, 165, 2, 0, 200, 184, 227, 17, 17, 193, 0, 13, + 254, 22, 12, 9, 75, 105, 118, 121, 66, 76, 69, 84, 101, 115, 116, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + ) + +def mock_beacon_scan_result(): + """0x4C, # Apple Manufacturer ID + bytes([ + 0x2, # SubType: Custom Manufacturer Data + 0x15 # Subtype lenth + ]) + + uuid + # UUID of beacon: UUID=8da683d6-e574-4a2e-bb9b-d83f2d05fc12 + bytes([ + 0, 15, # Major value + 0, 1, # Minor value + 10 # RSSI, dBm at 1m + ]) + """ + return mock_scan_result(bytes.fromhex('1AFF4C0002158DA683D6E5744A2EBB9BD83F2D05FC12000F00010A')) + +def mock_battery_scan_result(): + """Battery ("0000180f-0000-1000-8000-00805f9b34fb" or "180f" in short form) + service data: 34% (0x22) + """ + return mock_scan_result(bytes.fromhex('04160F1822')) + +beacon = mock_beacon_scan_result() +battery = mock_battery_scan_result() +testapp = mock_test_app_scan_result() +``` + +# Test device is found with scan filters set + +```python +%%there +results = Results() +ble.start_scan(filters=[ + DeviceNameFilter("KivyBLETest") & ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51b"), +]) +``` + +```python +sleep(10) +``` + +```python +%%there +ble.stop_scan() +``` + +```python +sleep(2) +``` + +```python +%%there +print(set([dev.getName() for dev in results.devices])) +``` + +# Test device is not found: filtered out by name + +```python +%%there +results = Results() +ble.start_scan(filters=[DeviceNameFilter("No-such-device-8458e2e35158")]) +``` + +```python +sleep(10) +``` + +```python +%%there +ble.stop_scan() +``` + +```python +sleep(2) +``` + +```python +%%there +print(results.devices) +``` + +# Test scan filter mathes + +```python +%%there +filter_matches(EmptyFilter(), testapp) +filter_matches(EmptyFilter(), beacon) +filter_matches(EmptyFilter(), battery) +``` + +```python +%%there +filter_matches(DeviceAddressFilter("AA:AA:AA:AA:AA:AA"), testapp) +filter_matches(DeviceAddressFilter("AA:AA:AA:AA:AA:AB"), testapp) +try: + filter_matches(DeviceAddressFilter("AA"), testapp) +except Exception as exc: + print(exc) +``` + +```python +%%there +filter_matches(DeviceNameFilter("KivyBLETest"), testapp) +filter_matches(DeviceNameFilter("KivyBLETes"), testapp) +``` + +```python +%%there +filter_matches(ManufacturerDataFilter(0x4c, []), testapp) +filter_matches(ManufacturerDataFilter(0x4c, []), beacon) +filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xe5]), beacon) +filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa]), beacon) +filter_matches( + ManufacturerDataFilter(0x4c, + [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa], + [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]), + beacon +) +filter_matches( + ManufacturerDataFilter(0x4c, + [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa], + [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), + beacon +) +filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0, 0x8d, 0xa6, 0x83], [0xff, 0, 0xff, 0xff, 0xff]), beacon) +filter_matches(ManufacturerDataFilter(0x4c, b'\x02\x15'), beacon) +filter_matches(ManufacturerDataFilter(0x4c, b'\x02\x16'), beacon) +``` + +```python +%%there +filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", []), battery) +filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fc", []), battery) + +filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x22]), battery) +filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21]), battery) +filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21], mask=[0xf0]), battery) +filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21], mask=[0x0f]), battery) +``` + +```python +%%there +filter_matches(ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51B"), testapp) +filter_matches(ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51C"), testapp) +filter_matches(ServiceUUIDFilter( + "16fe0d00-c111-11e3-b8c8-0002a5d5c51C", + "ffffffff-ffff-ffff-ffff-ffffffffffff" +), testapp) +filter_matches(ServiceUUIDFilter( + "16fe0d00-c111-11e3-b8c8-0002a5d5c51C", + "ffffffff-ffff-ffff-ffff-fffffffffff0" +), testapp) +``` diff --git a/libs/able/tests/notebooks/test_scan_settings.expected b/libs/able/tests/notebooks/test_scan_settings.expected new file mode 100644 index 0000000..8f3caef --- /dev/null +++ b/libs/able/tests/notebooks/test_scan_settings.expected @@ -0,0 +1,27 @@ +[[setup]] += Setup + +[[run-scan_mode_low_power]] += Run SCAN_MODE_LOW_POWER + + +---- +True +---- + +[[run-scan_mode_low_latency]] += Run SCAN_MODE_LOW_LATENCY + + +---- +True +---- + +[[check-that-received-advertisement-count-is-greater-with-scan_mode_low_latency]] += Check that received advertisement count is greater with +SCAN_MODE_LOW_LATENCY + + +---- +True +---- diff --git a/libs/able/tests/notebooks/test_scan_settings.md b/libs/able/tests/notebooks/test_scan_settings.md new file mode 100644 index 0000000..a231bb4 --- /dev/null +++ b/libs/able/tests/notebooks/test_scan_settings.md @@ -0,0 +1,105 @@ +--- +jupyter: + jupytext: + formats: ipynb,md + text_representation: + extension: .md + format_name: markdown + format_version: '1.3' + jupytext_version: 1.11.2 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +# Setup + +```python +%run init.ipynb +``` + +```python +%%there +class BLE(BluetoothDispatcher): + + def on_scan_started(self, success): + results.started = success + + def on_scan_completed(self): + results.completed = 1 + + def on_device(self, device, rssi, advertisement): + results.devices.append(device) + +def get_advertisemnt_count(): + return len([dev for dev in results.devices if dev.getName() == "KivyBLETest"]) + +ble = BLE() +``` + +# Run SCAN_MODE_LOW_POWER + +```python +%%there +results = Results() +ble.start_scan( + settings=ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) +) +``` + +```python +sleep(20) +``` + +```python +%%there +ble.stop_scan() +``` + +```python +sleep(2) +``` + +```python +%%there +low_power_advertisement_count = get_advertisemnt_count() +print(low_power_advertisement_count > 0) +``` + +# Run SCAN_MODE_LOW_LATENCY + +```python +%%there +results = Results() + +ble.start_scan( + settings=ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) +) +``` + +```python +sleep(20) +``` + +```python +%%there +ble.stop_scan() +``` + +```python +sleep(2) +``` + +```python +%%there +low_latency_advertisement_count = get_advertisemnt_count() +print(low_latency_advertisement_count > 0) +``` + +# Check that received advertisement count is greater with SCAN_MODE_LOW_LATENCY + +```python +%%there +print(low_latency_advertisement_count - low_power_advertisement_count > 2) +``` diff --git a/libs/able/tests/test_adapter.py b/libs/able/tests/test_adapter.py new file mode 100644 index 0000000..7b0f8c0 --- /dev/null +++ b/libs/able/tests/test_adapter.py @@ -0,0 +1,81 @@ +import pytest + +from able.adapter import ( + AdapterManager, + require_bluetooth_enabled, + set_adapter_failure_rollback, +) +from able.android.dispatcher import BluetoothDispatcher + + +@pytest.fixture +def manager(mocker): + return AdapterManager(mocker.Mock(), ..., []) + + +def test_operation_executed(mocker, manager): + operation = mocker.Mock() + logger = mocker.patch("able.adapter.Logger") + + manager.execute(operation) + + operation.assert_called_once() + logger.exception.assert_not_called() + + +def test_operation_failed_as_expected(mocker, manager): + manager.check_permissions = mocker.Mock(return_value=False) + expected = Exception("expected") + operation = mocker.Mock(side_effect=expected) + logger = mocker.patch("able.adapter.Logger") + + manager.execute(operation) + operation.assert_not_called() + + manager.check_permissions = mocker.Mock(return_value=True) + manager.execute_operations() + + operation.assert_called_once() + logger.exception.assert_called_once_with(expected) + + +def test_operations_executed(mocker, manager): + operations = [mocker.Mock(), mocker.Mock()] + manager.operations = operations.copy() + manager.check_permissions = mocker.Mock(return_value=False) + + manager.execute_operations() + + # permissions not granted = > suspended + calls = [operation.call_count for operation in manager.operations] + assert calls == [0, 0] + assert manager.operations == operations + + # one more operation requested + manager.execute(next_operation := mocker.Mock()) + + assert [operation.call_count for operation in manager.operations] == [0, 0, 0] + assert manager.operations == operations + [next_operation] + + manager.check_permissions = mocker.Mock(return_value=True) + manager.execute_operations() + assert not manager.operations + assert [operation.call_count for operation in operations + [next_operation]] == [ + 1, + 1, + 1, + ] + + +def test_rollback_performed(mocker, manager): + handlers = [mocker.Mock(), mocker.Mock()] + operations = [mocker.Mock(), mocker.Mock()] + + manager.operations = operations.copy() + manager.rollback_handlers = handlers.copy() + manager.rollback() + + assert not manager.rollback_handlers + assert not manager.operations + assert [operation.call_count for operation in operations] == [0, 0] + assert [operation.call_count for operation in handlers] == [1, 1] diff --git a/libs/able/tests/test_ble_queue.py b/libs/able/tests/test_ble_queue.py new file mode 100644 index 0000000..a6fcdc8 --- /dev/null +++ b/libs/able/tests/test_ble_queue.py @@ -0,0 +1,34 @@ +import unittest +import mock +from able.queue import ble_task + + +class TestBLETask(unittest.TestCase): + + def setUp(self): + self.queue = mock.Mock() + self.task_called = None + + @ble_task + def increment(self, a=1, b=0): + self.task_called = a + b + + def test_method_not_executed(self): + self.increment() + self.assertEqual(self.task_called, None) + + def test_task_enqued(self): + self.increment() + self.assertTrue(self.queue.enque.called) + + def test_task_default_arguments(self): + self.increment() + task = self.queue.enque.call_args[0][0] + task() + self.assertEqual(self.task_called, 1) + + def test_task_arguments_passed(self): + self.increment(200, 11) + task = self.queue.enque.call_args[0][0] + task() + self.assertEqual(self.task_called, 211) diff --git a/libs/able/tests/test_dispatcher.py b/libs/able/tests/test_dispatcher.py new file mode 100644 index 0000000..637c189 --- /dev/null +++ b/libs/able/tests/test_dispatcher.py @@ -0,0 +1,47 @@ +import pytest + +from able.android.dispatcher import BluetoothDispatcher + + +@pytest.fixture +def ble(mocker): + mocker.patch("able.android.dispatcher.PythonBluetooth") + ble = BluetoothDispatcher() + ble._ble = mocker.Mock() + ble.on_scan_started = mocker.Mock() + return ble + + +def test_adapter_returned(mocker, ble): + manager = ble._adapter_manager + manager.check_permissions = mocker.Mock(return_value=False) + assert not ble.adapter + assert not ble.adapter + + manager.check_permissions = mocker.Mock(return_value=True) + assert ble.adapter + + +def test_start_scan_executed(ble): + manager = ble._adapter_manager + assert manager + + ble.start_scan() + ble._ble.startScan.assert_called_once() + + +def test_start_scan_failed_as_expected(mocker, ble): + manager = ble._adapter_manager + manager.check_permissions = mocker.Mock(return_value=False) + + ble.start_scan() + ble._ble.startScan.assert_not_called() + + assert len(manager.operations) == 1 + assert len(manager.rollback_handlers) == 1 + + manager.on_runtime_permissions(permissions=[...], grant_results=[False]) + + ble.on_scan_started.assert_called_once_with(success=False) + assert len(manager.operations) == 0 + assert len(manager.rollback_handlers) == 0 diff --git a/libs/able/tests/test_filters.py b/libs/able/tests/test_filters.py new file mode 100644 index 0000000..bd2f446 --- /dev/null +++ b/libs/able/tests/test_filters.py @@ -0,0 +1,50 @@ +import pytest + +import able.filters as filters + + +@pytest.fixture +def java_builder(mocker): + instance = mocker.Mock() + mocker.patch("able.filters.ScanFilterBuilder", return_value=instance) + return instance + + +def test_filter_builded(java_builder): + filters.Filter().build() + assert java_builder.build.call_count == 1 + + +def test_builder_method_called(java_builder): + f = filters.DeviceNameFilter("test") + + f.build() + + assert java_builder.method_calls == [ + ("setDeviceName", ("test",)), + ("build", ) + ] + + +def test_filters_combined(java_builder): + f = filters.DeviceNameFilter("test") & ( + filters.DeviceAddressFilter("AA:AA:AA:AA:AA:AA") & + filters.ManufacturerDataFilter("test-id", [1, 2, 3]) + ) + + f.build() + + assert java_builder.method_calls == [ + ("setDeviceName", ("test",)), + ("setDeviceAddress", ("AA:AA:AA:AA:AA:AA",)), + ("setManufacturerData", ("test-id", [1, 2, 3])), + ("build", ) + ] + + +def test_combine_same_type_exception(java_builder): + with pytest.raises(ValueError, match="cannot combine filters of the same type"): + f = filters.DeviceNameFilter("test") & ( + filters.DeviceAddressFilter("AA:AA:AA:AA:AA:AA") & + filters.DeviceNameFilter("test2") + ) diff --git a/libs/able/tests/test_setup.py b/libs/able/tests/test_setup.py new file mode 100644 index 0000000..53dfca3 --- /dev/null +++ b/libs/able/tests/test_setup.py @@ -0,0 +1,30 @@ +from pathlib import Path +import pytest + + +@pytest.fixture +def parser(mocker): + mocker.patch("setuptools.setup") + from setup import PathParser + + return PathParser() + + +@pytest.mark.parametrize( + ("cppflags", "expected"), + [ + ( + "-I/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/alert_mi/", + "/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/javaclasses/alert_mi", + ), + ( + "-DANDROID -I/home/user/.buildozer/android/platform/android-ndk-r25b/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include -I/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/alert_mi/arm64-v8a/include/python3.9", + "/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/javaclasses/alert_mi", + ), + ], +) +def test_javaclass_dir_found(mocker, parser, cppflags, expected): + mocker.patch("os.environ", {"CPPFLAGS": cppflags}) + mocker.patch("pathlib.Path.exists", return_value=True) + mocker.patch("pathlib.Path.mkdir") + assert parser.javaclass_dir == Path(expected)