mirror of
https://github.com/markqvist/Sideband.git
synced 2025-11-09 10:45:04 -05:00
Use local version of able
This commit is contained in:
parent
2e44d49d6b
commit
9b6a51a03e
67 changed files with 5305 additions and 0 deletions
16
libs/able/.gitignore
vendored
Normal file
16
libs/able/.gitignore
vendored
Normal 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
103
libs/able/CHANGELOG.rst
Normal 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
22
libs/able/LICENSE
Normal 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
4
libs/able/MANIFEST.in
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
include LICENSE
|
||||
include README.rst
|
||||
include CHANGELOG.rst
|
||||
include able/src/org/able/*.java
|
||||
84
libs/able/able/__init__.py
Normal file
84
libs/able/able/__init__.py
Normal 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
179
libs/able/able/adapter.py
Normal 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()
|
||||
330
libs/able/able/advertising.py
Normal file
330
libs/able/able/advertising.py
Normal 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)
|
||||
0
libs/able/able/android/__init__.py
Normal file
0
libs/able/able/android/__init__.py
Normal file
105
libs/able/able/android/dispatcher.py
Normal file
105
libs/able/able/android/dispatcher.py
Normal 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)
|
||||
151
libs/able/able/android/jni.py
Normal file
151
libs/able/able/android/jni.py
Normal 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)
|
||||
371
libs/able/able/dispatcher.py
Normal file
371
libs/able/able/dispatcher.py
Normal 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
237
libs/able/able/filters.py
Normal 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)
|
||||
53
libs/able/able/permissions.py
Normal file
53
libs/able/able/permissions.py
Normal 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
90
libs/able/able/queue.py
Normal 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()
|
||||
20
libs/able/able/scan_settings.py
Normal file
20
libs/able/able/scan_settings.py
Normal 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')
|
||||
283
libs/able/able/src/org/able/BLE.java
Normal file
283
libs/able/able/src/org/able/BLE.java
Normal 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();
|
||||
}
|
||||
}
|
||||
61
libs/able/able/src/org/able/BLEAdvertiser.java
Normal file
61
libs/able/able/src/org/able/BLEAdvertiser.java
Normal 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;
|
||||
}
|
||||
}
|
||||
25
libs/able/able/src/org/able/PythonBluetooth.java
Normal file
25
libs/able/able/src/org/able/PythonBluetooth.java
Normal 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);
|
||||
}
|
||||
13
libs/able/able/src/org/able/PythonBluetoothAdvertiser.java
Normal file
13
libs/able/able/src/org/able/PythonBluetoothAdvertiser.java
Normal 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);
|
||||
}
|
||||
81
libs/able/able/structures.py
Normal file
81
libs/able/able/structures.py
Normal 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
42
libs/able/able/utils.py
Normal 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]
|
||||
5
libs/able/able/version.py
Normal file
5
libs/able/able/version.py
Normal 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
177
libs/able/docs/Makefile
Normal 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
154
libs/able/docs/api.rst
Normal 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
295
libs/able/docs/conf.py
Normal 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
192
libs/able/docs/example.rst
Normal 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
3
libs/able/docs/index.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.. include:: ../README.rst
|
||||
.. include:: api.rst
|
||||
.. include:: example.rst
|
||||
27
libs/able/examples/adapter_state_change.py
Normal file
27
libs/able/examples/adapter_state_change.py
Normal 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()
|
||||
71
libs/able/examples/advertising_battery.py
Normal file
71
libs/able/examples/advertising_battery.py
Normal 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()
|
||||
27
libs/able/examples/alert/buildozer.spec
Normal file
27
libs/able/examples/alert/buildozer.spec
Normal 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
|
||||
21
libs/able/examples/alert/error_message.kv
Normal file
21
libs/able/examples/alert/error_message.kv
Normal 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()
|
||||
39
libs/able/examples/alert/error_message.py
Normal file
39
libs/able/examples/alert/error_message.py
Normal 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())
|
||||
64
libs/able/examples/alert/main.py
Normal file
64
libs/able/examples/alert/main.py
Normal 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
52
libs/able/examples/mtu.py
Normal 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()
|
||||
27
libs/able/examples/multi_devices/buildozer.spec
Normal file
27
libs/able/examples/multi_devices/buildozer.spec
Normal 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
|
||||
96
libs/able/examples/multi_devices/main.py
Normal file
96
libs/able/examples/multi_devices/main.py
Normal 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()
|
||||
27
libs/able/examples/service_advertise/buildozer.spec
Normal file
27
libs/able/examples/service_advertise/buildozer.spec
Normal 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
|
||||
56
libs/able/examples/service_advertise/main.py
Normal file
56
libs/able/examples/service_advertise/main.py
Normal 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()
|
||||
28
libs/able/examples/service_advertise/service.py
Normal file
28
libs/able/examples/service_advertise/service.py
Normal 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()
|
||||
29
libs/able/examples/service_scan/buildozer.spec
Normal file
29
libs/able/examples/service_scan/buildozer.spec
Normal 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
|
||||
48
libs/able/examples/service_scan/main.py
Normal file
48
libs/able/examples/service_scan/main.py
Normal 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()
|
||||
27
libs/able/examples/service_scan/service.py
Normal file
27
libs/able/examples/service_scan/service.py
Normal 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()
|
||||
0
libs/able/recipes/__init__.py
Normal file
0
libs/able/recipes/__init__.py
Normal file
34
libs/able/recipes/able_recipe/__init__.py
Normal file
34
libs/able/recipes/able_recipe/__init__.py
Normal 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()
|
||||
9
libs/able/recipes/able_recipe/setup.py
Normal file
9
libs/able/recipes/able_recipe/setup.py
Normal 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
135
libs/able/setup.py
Normal 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
1
libs/able/testapps/bletest/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
server
|
||||
193
libs/able/testapps/bletest/bletestapp.kv
Normal file
193
libs/able/testapps/bletest/bletestapp.kv
Normal 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')
|
||||
17
libs/able/testapps/bletest/buildozer.spec
Normal file
17
libs/able/testapps/bletest/buildozer.spec
Normal 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
|
||||
245
libs/able/testapps/bletest/main.py
Normal file
245
libs/able/testapps/bletest/main.py
Normal 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()
|
||||
95
libs/able/testapps/bletest/server.go
Normal file
95
libs/able/testapps/bletest/server.go
Normal 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 {}
|
||||
}
|
||||
0
libs/able/tests/__init__.py
Normal file
0
libs/able/tests/__init__.py
Normal file
4
libs/able/tests/notebooks/.gitignore
vendored
Normal file
4
libs/able/tests/notebooks/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
there.env
|
||||
.ipynb_checkpoints/
|
||||
*.asciidoc
|
||||
*.ipynb
|
||||
43
libs/able/tests/notebooks/init.md
Normal file
43
libs/able/tests/notebooks/init.md
Normal 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
22
libs/able/tests/notebooks/run
Executable 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
|
||||
4
libs/able/tests/notebooks/run_all_tests
Executable file
4
libs/able/tests/notebooks/run_all_tests
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
for name in test_*.md; do ./run "${name%%.*}"; done
|
||||
26
libs/able/tests/notebooks/test_basic.expected
Normal file
26
libs/able/tests/notebooks/test_basic.expected
Normal 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
|
||||
----
|
||||
74
libs/able/tests/notebooks/test_basic.md
Normal file
74
libs/able/tests/notebooks/test_basic.md
Normal 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]
|
||||
)
|
||||
```
|
||||
72
libs/able/tests/notebooks/test_scan_filters.expected
Normal file
72
libs/able/tests/notebooks/test_scan_filters.expected
Normal 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
|
||||
----
|
||||
222
libs/able/tests/notebooks/test_scan_filters.md
Normal file
222
libs/able/tests/notebooks/test_scan_filters.md
Normal 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)
|
||||
```
|
||||
27
libs/able/tests/notebooks/test_scan_settings.expected
Normal file
27
libs/able/tests/notebooks/test_scan_settings.expected
Normal 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
|
||||
----
|
||||
105
libs/able/tests/notebooks/test_scan_settings.md
Normal file
105
libs/able/tests/notebooks/test_scan_settings.md
Normal 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)
|
||||
```
|
||||
81
libs/able/tests/test_adapter.py
Normal file
81
libs/able/tests/test_adapter.py
Normal 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]
|
||||
34
libs/able/tests/test_ble_queue.py
Normal file
34
libs/able/tests/test_ble_queue.py
Normal 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)
|
||||
47
libs/able/tests/test_dispatcher.py
Normal file
47
libs/able/tests/test_dispatcher.py
Normal 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
|
||||
50
libs/able/tests/test_filters.py
Normal file
50
libs/able/tests/test_filters.py
Normal 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")
|
||||
)
|
||||
30
libs/able/tests/test_setup.py
Normal file
30
libs/able/tests/test_setup.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue