mirror of
https://github.com/markqvist/Sideband.git
synced 2025-11-28 19:30:34 -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