Use local version of able

This commit is contained in:
Mark Qvist 2025-10-29 12:54:59 +01:00
parent 2e44d49d6b
commit 9b6a51a03e
67 changed files with 5305 additions and 0 deletions

16
libs/able/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
.buildozer/
bin/
docs/_build/
*~
*.swp
*.sublime-workspace
*.pyo
*.pyc
*.so
build/
dist/
sdist/
wheels/
*.egg-info

103
libs/able/CHANGELOG.rst Normal file
View file

@ -0,0 +1,103 @@
Changelog
=========
1.0.16
------
* Added `autoconnect` parameter to connection methods
`#45 <https://github.com/b3b/able/issues/45>`_
1.0.15
------
* Changing the wheel name to avoid installing a package from cache
`#40 <https://github.com/b3b/able/issues/40>`_
1.0.14
------
* Added event handler for bluetooth adapter state change
`#39 <https://github.com/b3b/able/pull/39>`_ by `robgar2001 <https://github.com/robgar2001>`_
* Removal of deprecated `convert_path` from setup script
1.0.13
------
* Fixed build failure when pip isolated environment is used `#38 <https://github.com/b3b/able/issues/38>`_
1.0.12
------
* Fixed crash on API level 31 (Android 12) `#37 <https://github.com/b3b/able/issues/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 <https://github.com/b3b/able/pull/33>`_ by `robgar2001 <https://github.com/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

22
libs/able/LICENSE Normal file
View file

@ -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.

4
libs/able/MANIFEST.in Normal file
View file

@ -0,0 +1,4 @@
include LICENSE
include README.rst
include CHANGELOG.rst
include able/src/org/able/*.java

View file

@ -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 <able.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

179
libs/able/able/adapter.py Normal file
View file

@ -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()

View file

@ -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
`<https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters.Builder#setInterval(int)>`_
:param tx_power: Transmission power level
`<https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters.Builder#setTxPowerLevel(int)>`_
>>> Advertiser(
... ble=BluetoothDispatcher(),
... data=AdvertiseData(DeviceName()),
... scan_data=AdvertiseData(TXPowerLevel()),
... interval=Interval.MIN,
... tx_power=TXPower.MAX
... ) #doctest: +ELLIPSIS
<able.advertising.Advertiser object at 0x...>
"""
__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)

View file

View file

@ -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)

View file

@ -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)

View file

@ -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 <https://developer.android.com/reference/android/bluetooth/BluetoothAdapter>`_
`Java object <https://pyjnius.readthedocs.io/en/stable/api.html#jnius.JavaClass>`_
"""
@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 <on_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 <on_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

237
libs/able/able/filters.py Normal file
View file

@ -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)

View file

@ -0,0 +1,53 @@
"""Before executing, all :class:`BluetoothDispatcher <able.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,
]

90
libs/able/able/queue.py Normal file
View file

@ -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()

View file

@ -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')

View file

@ -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<BluetoothGattService> 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<ScanFilter> 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<ScanResult> 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();
}
}

View file

@ -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;
}
}

View file

@ -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<BluetoothGattService> 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);
}

View file

@ -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);
}

View file

@ -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
<https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/>`_.
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

42
libs/able/able/utils.py Normal file
View file

@ -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]

View file

@ -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'

177
libs/able/docs/Makefile Normal file
View file

@ -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 <target>' where <target> 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."

154
libs/able/docs/api.rst Normal file
View file

@ -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

295
libs/able/docs/conf.py Normal file
View file

@ -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
# "<project> v<release> 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 <link> 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

192
libs/able/docs/example.rst Normal file
View file

@ -0,0 +1,192 @@
Usage Examples
==============
Alert
-----
.. literalinclude:: ./examples/alert.py
:language: python
Full example code: `alert <https://github.com/b3b/able/blob/master/examples/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 <https://github.com/b3b/able/blob/master/examples/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 <https://github.com/b3b/able/blob/master/examples/service_advertise/>`_
Connect to multiple devices
---------------------------
.. literalinclude:: ./examples/multi_devices/main.py
:language: python
Full example code: `multi_devices <https://github.com/b3b/able/blob/master/examples/multi_devices/>`_

3
libs/able/docs/index.rst Normal file
View file

@ -0,0 +1,3 @@
.. include:: ../README.rst
.. include:: api.rst
.. include:: example.rst

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -0,0 +1,21 @@
<ErrorMessage>:
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()

View file

@ -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())

View file

@ -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()

52
libs/able/examples/mtu.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

View file

@ -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()

View file

@ -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',
)

135
libs/able/setup.py Normal file
View file

@ -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",
},
},
)

1
libs/able/testapps/bletest/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
server

View file

@ -0,0 +1,193 @@
#:kivy 1.1.0
#: import Factory kivy.factory.Factory
#: import findall re.findall
<Caption@Label>:
padding_left: '4sp'
halign: 'left'
text_size: self.size
valign: 'middle'
<Value@Label>:
padding_left: '4sp'
halign: 'left'
text_size: self.size
valign: 'middle'
<ConnectByMACDialog@Popup>:
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()
<MainLayout>:
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')

View file

@ -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

View file

@ -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()

View file

@ -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 {}
}

View file

4
libs/able/tests/notebooks/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
there.env
.ipynb_checkpoints/
*.asciidoc
*.ipynb

View file

@ -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: [])
```

22
libs/able/tests/notebooks/run Executable file
View file

@ -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

View file

@ -0,0 +1,4 @@
#!/bin/bash
set -e
for name in test_*.md; do ./run "${name%%.*}"; done

View file

@ -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
----

View file

@ -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]
)
```

View file

@ -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
----

View file

@ -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)
```

View file

@ -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
----

View file

@ -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)
```

View file

@ -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]

View file

@ -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)

View file

@ -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

View file

@ -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")
)

View file

@ -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)