mirror of
https://github.com/markqvist/Sideband.git
synced 2025-11-09 02:35:05 -05:00
Compare commits
61 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7a83a39c | ||
|
|
a6618da229 | ||
|
|
4ee9623033 | ||
|
|
0c965d9259 | ||
|
|
dce43bdcfc | ||
|
|
92635ea1f2 | ||
|
|
3754f3bfa9 | ||
|
|
0112f85d34 | ||
|
|
829ccc6df9 | ||
|
|
6b09c6b262 | ||
|
|
cf0d64a746 | ||
|
|
7d0c9e8c4d | ||
|
|
e5dfa81b0a | ||
|
|
00b9517458 | ||
|
|
88e8db6118 | ||
|
|
ef53318d29 | ||
|
|
017a1c1b28 | ||
|
|
8cd00f29d9 | ||
|
|
c94e177405 | ||
|
|
14436bce08 | ||
|
|
36af5d805d | ||
|
|
4daf4ef40a | ||
|
|
8b191784f3 | ||
|
|
1bb61c1009 | ||
|
|
b7e24d8813 | ||
|
|
b3057dabf8 | ||
|
|
f3b0c71c7b | ||
|
|
2fe1527ba2 | ||
|
|
fc9e1bf86f | ||
|
|
ef0aed51a2 | ||
|
|
a2c2de49fe | ||
|
|
9a11bfded2 | ||
|
|
2eec1d0a50 | ||
|
|
9bd7a5970c | ||
|
|
0a1be10411 | ||
|
|
9a3cda077c | ||
|
|
42d859f96e | ||
|
|
6802161b4f | ||
|
|
2758b571ff | ||
|
|
9b6a51a03e | ||
|
|
2e44d49d6b | ||
|
|
d8581c981c | ||
|
|
165f480d7e | ||
|
|
3f0d91ecdb | ||
|
|
dfac5cbd4a | ||
|
|
88010b76fc | ||
|
|
4b3e3d9e4e | ||
|
|
6a133842db | ||
|
|
846e7d7687 | ||
|
|
26e115765f | ||
|
|
354fb08297 | ||
|
|
f0ec8fde42 | ||
|
|
73601ebe1e | ||
|
|
0d2f7b25a3 | ||
|
|
aee675d38b | ||
|
|
e6ef41815c | ||
|
|
2d7b5d2527 | ||
|
|
cd7562390c | ||
|
|
aaed27d4ac | ||
|
|
3c03070b6e | ||
|
|
668dd48cee |
118 changed files with 8758 additions and 2366 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -34,3 +34,6 @@ docs/build
|
|||
sideband*.egg-info
|
||||
sbapp*.egg-info
|
||||
LXST
|
||||
environment
|
||||
archived_build_tools
|
||||
.gradle
|
||||
|
|
|
|||
3
FUNDING.yml
Normal file
3
FUNDING.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
liberapay: Reticulum
|
||||
ko_fi: markqvist
|
||||
custom: "https://unsigned.io/donate"
|
||||
71
README.md
71
README.md
|
|
@ -46,39 +46,41 @@ After the application is installed on your Android device, it is also possible t
|
|||
|
||||
## On Linux
|
||||
|
||||
On all Linux-based operating systems, Sideband is available as a `pipx`/`pip` package. This installation method **includes desktop integration**, so that Sideband will show up in your applications menu and launchers. Below are install steps for the most common recent Linux distros. For Debian 11, see the end of this section.
|
||||
On all Linux-based operating systems, Sideband is available as a `pip`/`pipx` package. This installation method **includes desktop integration**, so that Sideband will show up in your applications menu and launchers. Below are install steps for the most common recent Linux distros. For Debian 11, see the end of this section.
|
||||
|
||||
#### Basic Installation
|
||||
You will first need to install a few dependencies for voice calls, audio messaging and Codec2 support to work:
|
||||
|
||||
```bash
|
||||
# For Debian (12+), Ubuntu (22.04+) and derivatives
|
||||
sudo apt install pipx python3-pyaudio python3-dev build-essential libopusfile0 portaudio19-dev codec2 xclip xsel
|
||||
# For Debian 13+, Ubuntu 24.04+ and derivatives
|
||||
sudo apt install python3-pip python3-pyaudio libopusfile0 codec2 xclip xsel
|
||||
|
||||
# For Debian 12+, Ubuntu 22.04+ and derivatives
|
||||
sudo apt install python3-pip python3-pyaudio python3-dev build-essential libopusfile0 portaudio19-dev codec2 xclip xsel
|
||||
|
||||
# For Manjaro and derivatives
|
||||
pamac install python-pipx python-pyaudio base-devel codec2 xclip xsel
|
||||
pamac install python-pyaudio codec2 xclip xsel
|
||||
|
||||
# For Arch and derivatives
|
||||
sudo pacman -Sy python-pipx python-pyaudio base-devel codec2 xclip xsel
|
||||
sudo pacman -Sy python-pyaudio codec2 xclip xsel
|
||||
|
||||
```
|
||||
|
||||
Once those are installed, install the Sideband application itself:
|
||||
|
||||
```bash
|
||||
# Finally, install Sideband using pipx:
|
||||
pipx install sbapp
|
||||
# Finally, install Sideband using pip:
|
||||
pip install sbapp --break-system-packages
|
||||
|
||||
# If Sideband does not show up in your application menu
|
||||
# or as an executable command in your terminal, reboot
|
||||
# your computer.
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After installation, you can now run Sideband in a number of different ways:
|
||||
|
||||
```bash
|
||||
# If this is the first time installing something with pipx,
|
||||
# you may need to use the following command, to make your
|
||||
# installed applications available. You'll probably need
|
||||
# to close and reopen your terminal after this.
|
||||
pipx ensurepath
|
||||
|
||||
# The first time you run Sideband, you will need to do it
|
||||
# from the terminal, for the application launcher item to
|
||||
# show up. On some distros you may also need to log out
|
||||
|
|
@ -106,11 +108,8 @@ If you do not already have Reticulum connectivity set up on your computer or loc
|
|||
You can also install Sideband in various alternative ways:
|
||||
|
||||
```bash
|
||||
# Install Sideband via pip instead of pipx:
|
||||
pip install sbapp
|
||||
|
||||
# Or, if pip is externally managed:
|
||||
pip install sbapp --break-system-packages
|
||||
# Install Sideband via pipx instead of pip:
|
||||
pipx install sbapp
|
||||
|
||||
# Or, if you intend to run Sideband in headless
|
||||
# daemon mode, you can also install it without
|
||||
|
|
@ -133,34 +132,40 @@ python3 -m sbapp.main
|
|||
|
||||
## On Raspberry Pi
|
||||
|
||||
You can install Sideband on all Raspberry Pi models that support 64-bit operating systems, and can run at least Python version 3.11. Since some of Sideband's dependencies don't have pre-built packages ready for 64-bit ARM processors yet, you'll need to install a few extra packages, that will allow building these while installing.
|
||||
You can install Sideband on all Raspberry Pi models that support 64-bit operating systems, and can run at least Python version 3.11.
|
||||
|
||||
Aditionally, the `pycodec2` package needs to be installed manually. I have provided a pre-built version, that you can download and install with a single command, or if you don't want to trust my pre-built version, you can [build and install it from source yourself](https://github.com/gregorias/pycodec2/blob/main/DEV.md).
|
||||
The install instructions below assume that you are installing Sideband on 64-bit Raspberry Pi OS (based on Debian 13 "Trixie" or later). If you're running something else on your Pi, you might need to modify some commands slightly.
|
||||
|
||||
The install instructions below assume that you are installing Sideband on 64-bit Raspberry Pi OS (based on Debian Bookworm or later). If you're running something else on your Pi, you might need to modify some commands slightly. To install Sideband on Raspberry Pi with full support for voice calls, audio messages and Codec2, follow these steps:
|
||||
To install Sideband on Raspberry Pi with full support for voice calls, audio messages and Codec2, follow these steps:
|
||||
|
||||
```bash
|
||||
# First of all, install the required dependencies:
|
||||
sudo apt install python3-pip python3-pyaudio python3-dev python3-cryptography build-essential libopusfile0 libsdl2-dev libavcodec-dev libavdevice-dev libavfilter-dev portaudio19-dev codec2 libcodec2-1.0 xclip xsel
|
||||
|
||||
# If you don't want to compile pycodec2 yourself,
|
||||
# download the pre-compiled package provided here:
|
||||
wget https://raw.githubusercontent.com/markqvist/Sideband/main/docs/utilities/pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl
|
||||
|
||||
# And install it:
|
||||
pip install ./pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl --break-system-packages
|
||||
# First of all, install the dependencies required
|
||||
# for audio processing and voice calls.
|
||||
#
|
||||
# On Raspberry Pi OS "Trixie" (based on Debian 13)
|
||||
# or newer, install these packages:
|
||||
sudo apt install python3-pyaudio codec2 xclip xsel
|
||||
|
||||
# You can now install Sideband
|
||||
pip install sbapp --break-system-packages
|
||||
|
||||
# Restart your Raspberry Pi
|
||||
# Restart your Raspberry Pi to ensure the program
|
||||
# is available in your PATH and application menus:
|
||||
sudo reboot
|
||||
|
||||
# Everything is ready! You can now run Sideband
|
||||
# from the terminal, or from the application menu
|
||||
# from the terminal, or from the application menu:
|
||||
sideband
|
||||
```
|
||||
|
||||
If you're using an older version of Raspberry Pi OS, you will need to install these dependencies instead, before installing Sideband:
|
||||
|
||||
```bash
|
||||
# Package dependencies for Raspberry Pi OS based
|
||||
# on Debian 12 Bookworm
|
||||
sudo apt install python3-pip python3-pyaudio python3-dev python3-cryptography build-essential libopusfile0 libsdl2-dev libavcodec-dev libavdevice-dev libavfilter-dev portaudio19-dev codec2 libcodec2-1.0 xclip xsel
|
||||
```
|
||||
|
||||
If you do not already have Reticulum connectivity set up on your computer or local network, you will probably want to edit the Reticulum configuration file at `~/.reticulum/config` and [add any interfaces](https://reticulum.network/manual/interfaces.html) you need for connectivity.
|
||||
|
||||
## On macOS
|
||||
|
|
@ -323,6 +328,8 @@ You can help support the continued development of open, free and private communi
|
|||
```
|
||||
0xae89F3B94fC4AD6563F0864a55F9a697a90261ff
|
||||
```
|
||||
- Liberapay: https://liberapay.com/Reticulum/
|
||||
|
||||
- Ko-Fi: https://ko-fi.com/markqvist
|
||||
|
||||
|
||||
|
|
|
|||
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)
|
||||
37
recipes/able_recipe/__init__.py
Normal file
37
recipes/able_recipe/__init__.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""
|
||||
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)
|
||||
|
||||
srcs = ('../../libs/able/able', 'setup.py')
|
||||
|
||||
for filename in srcs:
|
||||
print(f"Copy {join(self.get_recipe_dir(), filename)} to {build_dir}")
|
||||
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
recipes/able_recipe/setup.py
Normal file
9
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',
|
||||
)
|
||||
|
|
@ -2,6 +2,7 @@ from os.path import join
|
|||
from pythonforandroid.recipe import Recipe
|
||||
from pythonforandroid.toolchain import current_directory, shprint
|
||||
import sh
|
||||
import os
|
||||
|
||||
# For debugging, clean with
|
||||
# buildozer android p4a -- clean_recipe_build codec2 --local-recipes ~/Information/Source/Sideband/recipes
|
||||
|
|
|
|||
14
recipes/cython/__init__.py
Normal file
14
recipes/cython/__init__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from pythonforandroid.recipe import CompiledComponentsPythonRecipe
|
||||
|
||||
|
||||
class CythonRecipe(CompiledComponentsPythonRecipe):
|
||||
|
||||
version = '3.1.6'
|
||||
url = 'https://github.com/cython/cython/archive/{version}.tar.gz'
|
||||
site_packages_name = 'cython'
|
||||
depends = ['setuptools']
|
||||
call_hostpython_via_targetpython = False
|
||||
install_in_hostpython = True
|
||||
|
||||
|
||||
recipe = CythonRecipe()
|
||||
File diff suppressed because it is too large
Load diff
175
recipes/hostpython3/__init__.py
Normal file
175
recipes/hostpython3/__init__.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import sh
|
||||
import os
|
||||
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import Path
|
||||
from os.path import join
|
||||
|
||||
from packaging.version import Version
|
||||
from pythonforandroid.logger import shprint
|
||||
from pythonforandroid.recipe import Recipe
|
||||
from pythonforandroid.util import (
|
||||
BuildInterruptingException,
|
||||
current_directory,
|
||||
ensure_dir,
|
||||
)
|
||||
from pythonforandroid.prerequisites import OpenSSLPrerequisite
|
||||
|
||||
HOSTPYTHON_VERSION_UNSET_MESSAGE = (
|
||||
'The hostpython recipe must have set version'
|
||||
)
|
||||
|
||||
SETUP_DIST_NOT_FIND_MESSAGE = (
|
||||
'Could not find Setup.dist or Setup in Python build'
|
||||
)
|
||||
|
||||
|
||||
class HostPython3Recipe(Recipe):
|
||||
'''
|
||||
The hostpython3's recipe.
|
||||
|
||||
.. versionchanged:: 2019.10.06.post0
|
||||
Refactored from deleted class ``python.HostPythonRecipe`` into here.
|
||||
|
||||
.. versionchanged:: 0.6.0
|
||||
Refactored into the new class
|
||||
:class:`~pythonforandroid.python.HostPythonRecipe`
|
||||
'''
|
||||
|
||||
version = '3.11.5'
|
||||
name = 'hostpython3'
|
||||
|
||||
build_subdir = 'native-build'
|
||||
'''Specify the sub build directory for the hostpython3 recipe. Defaults
|
||||
to ``native-build``.'''
|
||||
|
||||
url = 'https://www.python.org/ftp/python/{version}/Python-{version}.tgz'
|
||||
'''The default url to download our host python recipe. This url will
|
||||
change depending on the python version set in attribute :attr:`version`.'''
|
||||
|
||||
patches = ['patches/pyconfig_detection.patch']
|
||||
|
||||
@property
|
||||
def _exe_name(self):
|
||||
'''
|
||||
Returns the name of the python executable depending on the version.
|
||||
'''
|
||||
if not self.version:
|
||||
raise BuildInterruptingException(HOSTPYTHON_VERSION_UNSET_MESSAGE)
|
||||
return f'python{self.version.split(".")[0]}'
|
||||
|
||||
@property
|
||||
def python_exe(self):
|
||||
'''Returns the full path of the hostpython executable.'''
|
||||
return join(self.get_path_to_python(), self._exe_name)
|
||||
|
||||
def get_recipe_env(self, arch=None):
|
||||
env = os.environ.copy()
|
||||
openssl_prereq = OpenSSLPrerequisite()
|
||||
if env.get("PKG_CONFIG_PATH", ""):
|
||||
env["PKG_CONFIG_PATH"] = os.pathsep.join(
|
||||
[openssl_prereq.pkg_config_location, env["PKG_CONFIG_PATH"]]
|
||||
)
|
||||
else:
|
||||
env["PKG_CONFIG_PATH"] = openssl_prereq.pkg_config_location
|
||||
return env
|
||||
|
||||
def should_build(self, arch):
|
||||
if Path(self.python_exe).exists():
|
||||
# no need to build, but we must set hostpython for our Context
|
||||
self.ctx.hostpython = self.python_exe
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_build_container_dir(self, arch=None):
|
||||
choices = self.check_recipe_choices()
|
||||
dir_name = '-'.join([self.name] + choices)
|
||||
return join(self.ctx.build_dir, 'other_builds', dir_name, 'desktop')
|
||||
|
||||
def get_build_dir(self, arch=None):
|
||||
'''
|
||||
.. note:: Unlike other recipes, the hostpython build dir doesn't
|
||||
depend on the target arch
|
||||
'''
|
||||
return join(self.get_build_container_dir(), self.name)
|
||||
|
||||
def get_path_to_python(self):
|
||||
return join(self.get_build_dir(), self.build_subdir)
|
||||
|
||||
@property
|
||||
def site_root(self):
|
||||
return join(self.get_path_to_python(), "root")
|
||||
|
||||
@property
|
||||
def site_bin(self):
|
||||
return join(self.site_root, self.site_dir, "bin")
|
||||
|
||||
@property
|
||||
def local_bin(self):
|
||||
return join(self.site_root, "usr/local/bin/")
|
||||
|
||||
@property
|
||||
def site_dir(self):
|
||||
p_version = Version(self.version)
|
||||
return join(
|
||||
self.site_root,
|
||||
f"usr/local/lib/python{p_version.major}.{p_version.minor}/site-packages/"
|
||||
)
|
||||
|
||||
def build_arch(self, arch):
|
||||
env = self.get_recipe_env(arch)
|
||||
|
||||
recipe_build_dir = self.get_build_dir(arch.arch)
|
||||
|
||||
# Create a subdirectory to actually perform the build
|
||||
build_dir = join(recipe_build_dir, self.build_subdir)
|
||||
ensure_dir(build_dir)
|
||||
|
||||
# Configure the build
|
||||
build_configured = False
|
||||
with current_directory(build_dir):
|
||||
if not Path('config.status').exists():
|
||||
shprint(sh.Command(join(recipe_build_dir, 'configure')), _env=env)
|
||||
build_configured = True
|
||||
|
||||
with current_directory(recipe_build_dir):
|
||||
# Create the Setup file. This copying from Setup.dist is
|
||||
# the normal and expected procedure before Python 3.8, but
|
||||
# after this the file with default options is already named "Setup"
|
||||
setup_dist_location = join('Modules', 'Setup.dist')
|
||||
if Path(setup_dist_location).exists():
|
||||
shprint(sh.cp, setup_dist_location,
|
||||
join(build_dir, 'Modules', 'Setup'))
|
||||
else:
|
||||
# Check the expected file does exist
|
||||
setup_location = join('Modules', 'Setup')
|
||||
if not Path(setup_location).exists():
|
||||
raise BuildInterruptingException(
|
||||
SETUP_DIST_NOT_FIND_MESSAGE
|
||||
)
|
||||
|
||||
shprint(sh.make, '-j', str(cpu_count()), '-C', build_dir, _env=env)
|
||||
|
||||
# make a copy of the python executable giving it the name we want,
|
||||
# because we got different python's executable names depending on
|
||||
# the fs being case-insensitive (Mac OS X, Cygwin...) or
|
||||
# case-sensitive (linux)...so this way we will have an unique name
|
||||
# for our hostpython, regarding the used fs
|
||||
for exe_name in ['python.exe', 'python']:
|
||||
exe = join(self.get_path_to_python(), exe_name)
|
||||
if Path(exe).is_file():
|
||||
shprint(sh.cp, exe, self.python_exe)
|
||||
break
|
||||
|
||||
ensure_dir(self.site_root)
|
||||
self.ctx.hostpython = self.python_exe
|
||||
if build_configured:
|
||||
print("RUNNING ENSUREPIP:"+self.site_root)
|
||||
shprint(
|
||||
sh.Command(self.python_exe), "-m", "ensurepip", "--root", self.site_root, "-U",
|
||||
_env={"HOME": "/tmp"}
|
||||
)
|
||||
print("RAN ENSUREPIP")
|
||||
|
||||
|
||||
recipe = HostPython3Recipe()
|
||||
13
recipes/hostpython3/patches/pyconfig_detection.patch
Normal file
13
recipes/hostpython3/patches/pyconfig_detection.patch
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
diff -Nru Python-3.8.2/Lib/site.py Python-3.8.2-new/Lib/site.py
|
||||
--- Python-3.8.2/Lib/site.py 2020-04-28 12:48:38.000000000 -0700
|
||||
+++ Python-3.8.2-new/Lib/site.py 2020-04-28 12:52:46.000000000 -0700
|
||||
@@ -487,7 +487,8 @@
|
||||
if key == 'include-system-site-packages':
|
||||
system_site = value.lower()
|
||||
elif key == 'home':
|
||||
- sys._home = value
|
||||
+ # this is breaking pyconfig.h path detection with venv
|
||||
+ print('Ignoring "sys._home = value" override', file=sys.stderr)
|
||||
|
||||
sys.prefix = sys.exec_prefix = site_prefix
|
||||
|
||||
4
recipes/jpeg/Application.mk
Normal file
4
recipes/jpeg/Application.mk
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
APP_OPTIM := release
|
||||
APP_ABI := all # or armeabi
|
||||
APP_MODULES := libjpeg
|
||||
APP_ALLOW_MISSING_DEPS := true
|
||||
58
recipes/jpeg/__init__.py
Normal file
58
recipes/jpeg/__init__.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from pythonforandroid.recipe import Recipe
|
||||
from pythonforandroid.logger import shprint
|
||||
from pythonforandroid.util import current_directory
|
||||
from os.path import join
|
||||
import sh
|
||||
|
||||
|
||||
class JpegRecipe(Recipe):
|
||||
'''
|
||||
.. versionchanged:: 0.6.0
|
||||
rewrote recipe to be build with clang and updated libraries to latest
|
||||
version of the official git repo.
|
||||
'''
|
||||
name = 'jpeg'
|
||||
version = '2.0.1'
|
||||
url = 'https://github.com/libjpeg-turbo/libjpeg-turbo/archive/{version}.tar.gz' # noqa
|
||||
built_libraries = {'libjpeg.a': '.', 'libturbojpeg.a': '.'}
|
||||
# we will require this below patch to build the shared library
|
||||
# patches = ['remove-version.patch']
|
||||
|
||||
def build_arch(self, arch):
|
||||
build_dir = self.get_build_dir(arch.arch)
|
||||
|
||||
# TODO: Fix simd/neon
|
||||
with current_directory(build_dir):
|
||||
env = self.get_recipe_env(arch)
|
||||
toolchain_file = join(self.ctx.ndk_dir,
|
||||
'build/cmake/android.toolchain.cmake')
|
||||
|
||||
shprint(sh.rm, '-rf', 'CMakeCache.txt', 'CMakeFiles/')
|
||||
shprint(sh.cmake, '-G', 'Unix Makefiles',
|
||||
'-DCMAKE_SYSTEM_NAME=Android',
|
||||
'-DCMAKE_POSITION_INDEPENDENT_CODE=1',
|
||||
'-DCMAKE_ANDROID_ARCH_ABI={arch}'.format(arch=arch.arch),
|
||||
'-DCMAKE_ANDROID_NDK=' + self.ctx.ndk_dir,
|
||||
'-DCMAKE_C_COMPILER={cc}'.format(cc=arch.get_clang_exe()),
|
||||
'-DCMAKE_CXX_COMPILER={cc_plus}'.format(
|
||||
cc_plus=arch.get_clang_exe(plus_plus=True)),
|
||||
'-DCMAKE_BUILD_TYPE=Release',
|
||||
'-DCMAKE_INSTALL_PREFIX=./install',
|
||||
'-DCMAKE_TOOLCHAIN_FILE=' + toolchain_file,
|
||||
|
||||
'-DANDROID_ABI={arch}'.format(arch=arch.arch),
|
||||
'-DANDROID_ARM_NEON=ON',
|
||||
'-DENABLE_NEON=ON',
|
||||
# '-DREQUIRE_SIMD=1',
|
||||
|
||||
# Force disable shared, with the static ones is enough
|
||||
'-DENABLE_SHARED=0',
|
||||
'-DENABLE_STATIC=1',
|
||||
|
||||
# Fix cmake compatibility issue
|
||||
'-DCMAKE_POLICY_VERSION_MINIMUM=3.5',
|
||||
_env=env)
|
||||
shprint(sh.make, _env=env)
|
||||
|
||||
|
||||
recipe = JpegRecipe()
|
||||
85
recipes/jpeg/build-static.patch
Normal file
85
recipes/jpeg/build-static.patch
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
diff -Naur jpeg/Android.mk b/Android.mk
|
||||
--- jpeg/Android.mk 2015-12-14 11:37:25.900190235 -0600
|
||||
+++ b/Android.mk 2015-12-14 11:41:27.532182210 -0600
|
||||
@@ -54,8 +54,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(libjpeg_SOURCES_DIST)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libcutils
|
||||
-LOCAL_STATIC_LIBRARIES := libsimd
|
||||
+LOCAL_STATIC_LIBRARIES := libsimd libcutils
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)
|
||||
|
||||
@@ -68,7 +67,7 @@
|
||||
|
||||
LOCAL_MODULE := libjpeg
|
||||
|
||||
-include $(BUILD_SHARED_LIBRARY)
|
||||
+include $(BUILD_STATIC_LIBRARY)
|
||||
|
||||
######################################################
|
||||
### cjpeg ###
|
||||
@@ -82,7 +81,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(cjpeg_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH) \
|
||||
$(LOCAL_PATH)/android
|
||||
@@ -110,7 +109,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(djpeg_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH) \
|
||||
$(LOCAL_PATH)/android
|
||||
@@ -137,7 +136,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(jpegtran_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH) \
|
||||
$(LOCAL_PATH)/android
|
||||
@@ -163,7 +162,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(tjunittest_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)
|
||||
|
||||
@@ -189,7 +188,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(tjbench_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)
|
||||
|
||||
@@ -215,7 +214,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(rdjpgcom_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)
|
||||
|
||||
@@ -240,7 +239,7 @@
|
||||
|
||||
LOCAL_SRC_FILES:= $(wrjpgcom_SOURCES)
|
||||
|
||||
-LOCAL_SHARED_LIBRARIES := libjpeg
|
||||
+LOCAL_STATIC_LIBRARIES := libjpeg
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)
|
||||
|
||||
12
recipes/jpeg/remove-version.patch
Normal file
12
recipes/jpeg/remove-version.patch
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
--- jpeg/CMakeLists.txt.orig 2018-11-12 20:20:28.000000000 +0100
|
||||
+++ jpeg/CMakeLists.txt 2018-12-14 12:43:45.338704504 +0100
|
||||
@@ -573,6 +573,9 @@
|
||||
add_library(turbojpeg SHARED ${TURBOJPEG_SOURCES})
|
||||
set_property(TARGET turbojpeg PROPERTY COMPILE_FLAGS
|
||||
"-DBMP_SUPPORTED -DPPM_SUPPORTED")
|
||||
+ set_property(TARGET jpeg PROPERTY NO_SONAME 1)
|
||||
+ set_property(TARGET turbojpeg PROPERTY NO_SONAME 1)
|
||||
+ set(CMAKE_SHARED_LIBRARY_SONAME_C_FLAG "")
|
||||
if(WIN32)
|
||||
set_target_properties(turbojpeg PROPERTIES DEFINE_SYMBOL DLLDEFINE)
|
||||
endif()
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
from pythonforandroid.recipe import CompiledComponentsPythonRecipe
|
||||
from pythonforandroid.logger import shprint, info
|
||||
from pythonforandroid.util import current_directory
|
||||
from multiprocessing import cpu_count
|
||||
from os.path import join
|
||||
import glob
|
||||
import sh
|
||||
import shutil
|
||||
|
||||
|
||||
class NumpyRecipe(CompiledComponentsPythonRecipe):
|
||||
|
||||
version = '1.22.3'
|
||||
url = 'https://pypi.python.org/packages/source/n/numpy/numpy-{version}.zip'
|
||||
site_packages_name = 'numpy'
|
||||
depends = ['setuptools', 'cython']
|
||||
install_in_hostpython = True
|
||||
call_hostpython_via_targetpython = False
|
||||
|
||||
patches = [
|
||||
join("patches", "remove-default-paths.patch"),
|
||||
join("patches", "add_libm_explicitly_to_build.patch"),
|
||||
join("patches", "ranlib.patch"),
|
||||
]
|
||||
|
||||
def get_recipe_env(self, arch=None, with_flags_in_cc=True):
|
||||
env = super().get_recipe_env(arch, with_flags_in_cc)
|
||||
|
||||
# _PYTHON_HOST_PLATFORM declares that we're cross-compiling
|
||||
# and avoids issues when building on macOS for Android targets.
|
||||
env["_PYTHON_HOST_PLATFORM"] = arch.command_prefix
|
||||
|
||||
# NPY_DISABLE_SVML=1 allows numpy to build for non-AVX512 CPUs
|
||||
# See: https://github.com/numpy/numpy/issues/21196
|
||||
env["NPY_DISABLE_SVML"] = "1"
|
||||
|
||||
return env
|
||||
|
||||
def _build_compiled_components(self, arch):
|
||||
info('Building compiled components in {}'.format(self.name))
|
||||
|
||||
env = self.get_recipe_env(arch)
|
||||
with current_directory(self.get_build_dir(arch.arch)):
|
||||
hostpython = sh.Command(self.hostpython_location)
|
||||
shprint(hostpython, 'setup.py', self.build_cmd, '-v',
|
||||
_env=env, *self.setup_extra_args)
|
||||
build_dir = glob.glob('build/lib.*')[0]
|
||||
shprint(sh.find, build_dir, '-name', '"*.o"', '-exec',
|
||||
env['STRIP'], '{}', ';', _env=env)
|
||||
|
||||
def _rebuild_compiled_components(self, arch, env):
|
||||
info('Rebuilding compiled components in {}'.format(self.name))
|
||||
|
||||
hostpython = sh.Command(self.real_hostpython_location)
|
||||
shprint(hostpython, 'setup.py', 'clean', '--all', '--force', _env=env)
|
||||
shprint(hostpython, 'setup.py', self.build_cmd, '-v', _env=env,
|
||||
*self.setup_extra_args)
|
||||
|
||||
def build_compiled_components(self, arch):
|
||||
self.setup_extra_args = ['-j', str(cpu_count())]
|
||||
self._build_compiled_components(arch)
|
||||
self.setup_extra_args = []
|
||||
|
||||
def rebuild_compiled_components(self, arch, env):
|
||||
self.setup_extra_args = ['-j', str(cpu_count())]
|
||||
self._rebuild_compiled_components(arch, env)
|
||||
self.setup_extra_args = []
|
||||
|
||||
def get_hostrecipe_env(self, arch):
|
||||
env = super().get_hostrecipe_env(arch)
|
||||
env['RANLIB'] = shutil.which('ranlib')
|
||||
return env
|
||||
|
||||
|
||||
recipe = NumpyRecipe()
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
diff --git a/numpy/linalg/setup.py b/numpy/linalg/setup.py
|
||||
index 66c07c9..d34bd93 100644
|
||||
--- a/numpy/linalg/setup.py
|
||||
+++ b/numpy/linalg/setup.py
|
||||
@@ -46,6 +46,7 @@ def configuration(parent_package='', top_path=None):
|
||||
sources=['lapack_litemodule.c', get_lapack_lite_sources],
|
||||
depends=['lapack_lite/f2c.h'],
|
||||
extra_info=lapack_info,
|
||||
+ libraries=['m'],
|
||||
)
|
||||
|
||||
# umath_linalg module
|
||||
@@ -54,7 +54,7 @@ def configuration(parent_package='', top_path=None):
|
||||
sources=['umath_linalg.c.src', get_lapack_lite_sources],
|
||||
depends=['lapack_lite/f2c.h'],
|
||||
extra_info=lapack_info,
|
||||
- libraries=['npymath'],
|
||||
+ libraries=['npymath', 'm'],
|
||||
)
|
||||
return config
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
diff -Naur numpy.orig/numpy/distutils/unixccompiler.py numpy/numpy/distutils/unixccompiler.py
|
||||
--- numpy.orig/numpy/distutils/unixccompiler.py 2022-05-28 10:22:10.000000000 +0200
|
||||
+++ numpy/numpy/distutils/unixccompiler.py 2022-05-28 10:22:24.000000000 +0200
|
||||
@@ -124,6 +124,7 @@
|
||||
# platform intelligence here to skip ranlib if it's not
|
||||
# needed -- or maybe Python's configure script took care of
|
||||
# it for us, hence the check for leading colon.
|
||||
+ self.ranlib = [os.environ.get('RANLIB')]
|
||||
if self.ranlib:
|
||||
display = '%s:@ %s' % (os.path.basename(self.ranlib[0]),
|
||||
output_filename)
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
diff --git a/numpy/distutils/system_info.py b/numpy/distutils/system_info.py
|
||||
index fc7018a..7b514bc 100644
|
||||
--- a/numpy/distutils/system_info.py
|
||||
+++ b/numpy/distutils/system_info.py
|
||||
@@ -340,10 +340,10 @@ if os.path.join(sys.prefix, 'lib') not in default_lib_dirs:
|
||||
default_include_dirs.append(os.path.join(sys.prefix, 'include'))
|
||||
default_src_dirs.append(os.path.join(sys.prefix, 'src'))
|
||||
|
||||
-default_lib_dirs = [_m for _m in default_lib_dirs if os.path.isdir(_m)]
|
||||
-default_runtime_dirs = [_m for _m in default_runtime_dirs if os.path.isdir(_m)]
|
||||
-default_include_dirs = [_m for _m in default_include_dirs if os.path.isdir(_m)]
|
||||
-default_src_dirs = [_m for _m in default_src_dirs if os.path.isdir(_m)]
|
||||
+default_lib_dirs = [] #[_m for _m in default_lib_dirs if os.path.isdir(_m)]
|
||||
+default_runtime_dirs =[] # [_m for _m in default_runtime_dirs if os.path.isdir(_m)]
|
||||
+default_include_dirs =[] # [_m for _m in default_include_dirs if os.path.isdir(_m)]
|
||||
+default_src_dirs =[] # [_m for _m in default_src_dirs if os.path.isdir(_m)]
|
||||
|
||||
so_ext = get_shared_lib_extension()
|
||||
|
||||
@@ -814,7 +814,7 @@ class system_info(object):
|
||||
path = self.get_paths(self.section, key)
|
||||
if path == ['']:
|
||||
path = []
|
||||
- return path
|
||||
+ return []
|
||||
|
||||
def get_include_dirs(self, key='include_dirs'):
|
||||
return self.get_paths(self.section, key)
|
||||
|
|
@ -5,7 +5,7 @@ import sh
|
|||
|
||||
# class PyCodec2Recipe(IncludedFilesBehaviour, CythonRecipe):
|
||||
class PyCodec2Recipe(CythonRecipe):
|
||||
url = "https://github.com/markqvist/pycodec2/archive/refs/heads/main.zip"
|
||||
url = "https://github.com/markqvist/pycodec2/archive/438ee4f2f3ee30635a34caddf520cfaccdbbc646.zip"
|
||||
# src_filename = "../../../pycodec2"
|
||||
depends = ["setuptools", "numpy", "Cython", "codec2"]
|
||||
call_hostpython_via_targetpython = False
|
||||
|
|
|
|||
44
recipes/pyjnius/__init__.py
Normal file
44
recipes/pyjnius/__init__.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from pythonforandroid.recipe import PyProjectRecipe
|
||||
from pythonforandroid.toolchain import shprint, current_directory, info
|
||||
from pythonforandroid.patching import will_build
|
||||
import sh
|
||||
from os.path import join
|
||||
|
||||
|
||||
class PyjniusRecipe(PyProjectRecipe):
|
||||
version = '1.7.0'
|
||||
url = 'https://github.com/kivy/pyjnius/archive/{version}.zip'
|
||||
name = 'pyjnius'
|
||||
depends = [('genericndkbuild', 'sdl2', 'sdl3'), 'six']
|
||||
site_packages_name = 'jnius'
|
||||
hostpython_prerequisites = ["Cython<3.2"]
|
||||
patches = [
|
||||
"use_cython.patch",
|
||||
('genericndkbuild_jnienv_getter.patch', will_build('genericndkbuild')),
|
||||
('sdl3_jnienv_getter.patch', will_build('sdl3')),
|
||||
]
|
||||
|
||||
def get_recipe_env(self, arch, **kwargs):
|
||||
env = super().get_recipe_env(arch, **kwargs)
|
||||
|
||||
# Taken from CythonRecipe
|
||||
env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format(
|
||||
self.ctx.get_libs_dir(arch.arch) +
|
||||
' -L{} '.format(self.ctx.libs_dir) +
|
||||
' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local',
|
||||
arch.arch)))
|
||||
env['LDSHARED'] = env['CC'] + ' -shared'
|
||||
env['LIBLINK'] = 'NOTNONE'
|
||||
|
||||
# NDKPLATFORM is our switch for detecting Android platform, so can't be None
|
||||
env['NDKPLATFORM'] = "NOTNONE"
|
||||
return env
|
||||
|
||||
def postbuild_arch(self, arch):
|
||||
super().postbuild_arch(arch)
|
||||
info('Copying pyjnius java class to classes build dir')
|
||||
with current_directory(self.get_build_dir(arch.arch)):
|
||||
shprint(sh.cp, '-a', join('jnius', 'src', 'org'), self.ctx.javaclass_dir)
|
||||
|
||||
|
||||
recipe = PyjniusRecipe()
|
||||
24
recipes/pyjnius/genericndkbuild_jnienv_getter.patch
Normal file
24
recipes/pyjnius/genericndkbuild_jnienv_getter.patch
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
diff -Naur pyjnius.orig/jnius/env.py pyjnius/jnius/env.py
|
||||
--- pyjnius.orig/jnius/env.py 2022-05-28 11:16:02.000000000 +0200
|
||||
+++ pyjnius/jnius/env.py 2022-05-28 11:18:30.000000000 +0200
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
class AndroidJavaLocation(UnixJavaLocation):
|
||||
def get_libraries(self):
|
||||
- return ['SDL2', 'log']
|
||||
+ return ['main', 'log']
|
||||
|
||||
def get_include_dirs(self):
|
||||
# When cross-compiling for Android, we should not use the include dirs
|
||||
diff -Naur pyjnius.orig/jnius/jnius_jvm_android.pxi pyjnius/jnius/jnius_jvm_android.pxi
|
||||
--- pyjnius.orig/jnius/jnius_jvm_android.pxi 2022-05-28 11:16:02.000000000 +0200
|
||||
+++ pyjnius/jnius/jnius_jvm_android.pxi 2022-05-28 11:17:17.000000000 +0200
|
||||
@@ -1,6 +1,6 @@
|
||||
# on android, rely on SDL to get the JNI env
|
||||
-cdef extern JNIEnv *SDL_AndroidGetJNIEnv()
|
||||
+cdef extern JNIEnv *WebView_AndroidGetJNIEnv()
|
||||
|
||||
|
||||
cdef JNIEnv *get_platform_jnienv() except NULL:
|
||||
- return <JNIEnv*>SDL_AndroidGetJNIEnv()
|
||||
+ return <JNIEnv*>WebView_AndroidGetJNIEnv()
|
||||
24
recipes/pyjnius/sdl3_jnienv_getter.patch
Normal file
24
recipes/pyjnius/sdl3_jnienv_getter.patch
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
diff -Naur pyjnius.orig/jnius/env.py pyjnius/jnius/env.py
|
||||
--- pyjnius.orig/jnius/env.py 2022-05-28 11:16:02.000000000 +0200
|
||||
+++ pyjnius/jnius/env.py 2022-05-28 11:18:30.000000000 +0200
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
class AndroidJavaLocation(UnixJavaLocation):
|
||||
def get_libraries(self):
|
||||
- return ['SDL2', 'log']
|
||||
+ return ['SDL3', 'log']
|
||||
|
||||
def get_include_dirs(self):
|
||||
# When cross-compiling for Android, we should not use the include dirs
|
||||
diff -Naur pyjnius.orig/jnius/jnius_jvm_android.pxi pyjnius/jnius/jnius_jvm_android.pxi
|
||||
--- pyjnius.orig/jnius/jnius_jvm_android.pxi 2022-05-28 11:16:02.000000000 +0200
|
||||
+++ pyjnius/jnius/jnius_jvm_android.pxi 2022-05-28 11:17:17.000000000 +0200
|
||||
@@ -1,6 +1,6 @@
|
||||
# on android, rely on SDL to get the JNI env
|
||||
-cdef extern JNIEnv *SDL_AndroidGetJNIEnv()
|
||||
+cdef extern JNIEnv *SDL_GetAndroidJNIEnv()
|
||||
|
||||
|
||||
cdef JNIEnv *get_platform_jnienv() except NULL:
|
||||
- return <JNIEnv*>SDL_AndroidGetJNIEnv()
|
||||
+ return <JNIEnv*>SDL_GetAndroidJNIEnv()
|
||||
13
recipes/pyjnius/use_cython.patch
Normal file
13
recipes/pyjnius/use_cython.patch
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
--- pyjnius-1.6.1/setup.py 2023-11-05 21:07:43.000000000 +0530
|
||||
+++ pyjnius-1.6.1.mod/setup.py 2025-03-01 14:47:11.964847337 +0530
|
||||
@@ -59,10 +59,6 @@
|
||||
if NDKPLATFORM is not None and getenv('LIBLINK'):
|
||||
PLATFORM = 'android'
|
||||
|
||||
-# detect platform
|
||||
-if PLATFORM == 'android':
|
||||
- PYX_FILES = [fn[:-3] + 'c' for fn in PYX_FILES]
|
||||
-
|
||||
JAVA=get_java_setup(PLATFORM)
|
||||
|
||||
assert JAVA.is_jdk(), "You need a JDK, we only found a JRE. Try setting JAVA_HOME"
|
||||
445
recipes/python3/__init__.py
Normal file
445
recipes/python3/__init__.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import glob
|
||||
import sh
|
||||
import subprocess
|
||||
|
||||
from os import environ, utime
|
||||
from os.path import dirname, exists, join
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from pythonforandroid.logger import info, warning, shprint
|
||||
from pythonforandroid.patching import version_starts_with
|
||||
from pythonforandroid.recipe import Recipe, TargetPythonRecipe
|
||||
from pythonforandroid.util import (
|
||||
current_directory,
|
||||
ensure_dir,
|
||||
walk_valid_filens,
|
||||
BuildInterruptingException,
|
||||
)
|
||||
|
||||
NDK_API_LOWER_THAN_SUPPORTED_MESSAGE = (
|
||||
'Target ndk-api is {ndk_api}, '
|
||||
'but the python3 recipe supports only {min_ndk_api}+'
|
||||
)
|
||||
|
||||
|
||||
class Python3Recipe(TargetPythonRecipe):
|
||||
'''
|
||||
The python3's recipe
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The python 3 recipe can be built with some extra python modules, but to do
|
||||
so, we need some libraries. By default, we ship the python3 recipe with
|
||||
some common libraries, defined in ``depends``. We also support some optional
|
||||
libraries, which are less common that the ones defined in ``depends``, so
|
||||
we added them as optional dependencies (``opt_depends``).
|
||||
|
||||
Below you have a relationship between the python modules and the recipe
|
||||
libraries::
|
||||
|
||||
- _ctypes: you must add the recipe for ``libffi``.
|
||||
- _sqlite3: you must add the recipe for ``sqlite3``.
|
||||
- _ssl: you must add the recipe for ``openssl``.
|
||||
- _bz2: you must add the recipe for ``libbz2`` (optional).
|
||||
- _lzma: you must add the recipe for ``liblzma`` (optional).
|
||||
|
||||
.. note:: This recipe can be built only against API 21+.
|
||||
|
||||
.. versionchanged:: 2019.10.06.post0
|
||||
- Refactored from deleted class ``python.GuestPythonRecipe`` into here
|
||||
- Added optional dependencies: :mod:`~pythonforandroid.recipes.libbz2`
|
||||
and :mod:`~pythonforandroid.recipes.liblzma`
|
||||
|
||||
.. versionchanged:: 0.6.0
|
||||
Refactored into class
|
||||
:class:`~pythonforandroid.python.GuestPythonRecipe`
|
||||
'''
|
||||
|
||||
version = '3.11.5'
|
||||
url = 'https://www.python.org/ftp/python/{version}/Python-{version}.tgz'
|
||||
name = 'python3'
|
||||
|
||||
patches = [
|
||||
'patches/pyconfig_detection.patch',
|
||||
'patches/reproducible-buildinfo.diff',
|
||||
|
||||
# Python 3.7.1
|
||||
('patches/py3.7.1_fix-ctypes-util-find-library.patch', version_starts_with("3.7")),
|
||||
('patches/py3.7.1_fix-zlib-version.patch', version_starts_with("3.7")),
|
||||
|
||||
# Python 3.8.1 & 3.9.X
|
||||
('patches/py3.8.1.patch', version_starts_with("3.8")),
|
||||
('patches/py3.8.1.patch', version_starts_with("3.9")),
|
||||
('patches/py3.8.1.patch', version_starts_with("3.10")),
|
||||
('patches/cpython-311-ctypes-find-library.patch', version_starts_with("3.11")),
|
||||
]
|
||||
|
||||
if shutil.which('lld') is not None:
|
||||
patches += [
|
||||
("patches/py3.7.1_fix_cortex_a8.patch", version_starts_with("3.7")),
|
||||
("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.8")),
|
||||
("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.9")),
|
||||
("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.10")),
|
||||
("patches/py3.8.1_fix_cortex_a8.patch", version_starts_with("3.11")),
|
||||
]
|
||||
|
||||
depends = ['hostpython3', 'sqlite3', 'openssl', 'libffi']
|
||||
# those optional depends allow us to build python compression modules:
|
||||
# - _bz2.so
|
||||
# - _lzma.so
|
||||
opt_depends = ['libbz2', 'liblzma']
|
||||
'''The optional libraries which we would like to get our python linked'''
|
||||
|
||||
configure_args = (
|
||||
'--host={android_host}',
|
||||
'--build={android_build}',
|
||||
'--enable-shared',
|
||||
'--enable-ipv6',
|
||||
'ac_cv_file__dev_ptmx=yes',
|
||||
'ac_cv_file__dev_ptc=no',
|
||||
'--without-ensurepip',
|
||||
'ac_cv_little_endian_double=yes',
|
||||
'ac_cv_header_sys_eventfd_h=no',
|
||||
'--prefix={prefix}',
|
||||
'--exec-prefix={exec_prefix}',
|
||||
'--enable-loadable-sqlite-extensions'
|
||||
)
|
||||
|
||||
if version_starts_with("3.11"):
|
||||
configure_args += ('--with-build-python={python_host_bin}',)
|
||||
|
||||
'''The configure arguments needed to build the python recipe. Those are
|
||||
used in method :meth:`build_arch` (if not overwritten like python3's
|
||||
recipe does).
|
||||
'''
|
||||
|
||||
MIN_NDK_API = 21
|
||||
'''Sets the minimal ndk api number needed to use the recipe.
|
||||
|
||||
.. warning:: This recipe can be built only against API 21+, so it means
|
||||
that any class which inherits from class:`GuestPythonRecipe` will have
|
||||
this limitation.
|
||||
'''
|
||||
|
||||
stdlib_dir_blacklist = {
|
||||
'__pycache__',
|
||||
'test',
|
||||
'tests',
|
||||
'lib2to3',
|
||||
'ensurepip',
|
||||
'idlelib',
|
||||
'tkinter',
|
||||
}
|
||||
'''The directories that we want to omit for our python bundle'''
|
||||
|
||||
stdlib_filen_blacklist = [
|
||||
'*.py',
|
||||
'*.exe',
|
||||
'*.whl',
|
||||
]
|
||||
'''The file extensions that we want to blacklist for our python bundle'''
|
||||
|
||||
site_packages_dir_blacklist = {
|
||||
'__pycache__',
|
||||
'tests'
|
||||
}
|
||||
'''The directories from site packages dir that we don't want to be included
|
||||
in our python bundle.'''
|
||||
|
||||
site_packages_filen_blacklist = [
|
||||
'*.py'
|
||||
]
|
||||
'''The file extensions from site packages dir that we don't want to be
|
||||
included in our python bundle.'''
|
||||
|
||||
compiled_extension = '.pyc'
|
||||
'''the default extension for compiled python files.
|
||||
|
||||
.. note:: the default extension for compiled python files has been .pyo for
|
||||
python 2.x-3.4 but as of Python 3.5, the .pyo filename extension is no
|
||||
longer used and has been removed in favour of extension .pyc
|
||||
'''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._ctx = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _libpython(self):
|
||||
'''return the python's library name (with extension)'''
|
||||
return 'libpython{link_version}.so'.format(
|
||||
link_version=self.link_version
|
||||
)
|
||||
|
||||
@property
|
||||
def link_version(self):
|
||||
'''return the python's library link version e.g. 3.7m, 3.8'''
|
||||
major, minor = self.major_minor_version_string.split('.')
|
||||
flags = ''
|
||||
if major == '3' and int(minor) < 8:
|
||||
flags += 'm'
|
||||
return '{major}.{minor}{flags}'.format(
|
||||
major=major,
|
||||
minor=minor,
|
||||
flags=flags
|
||||
)
|
||||
|
||||
def include_root(self, arch_name):
|
||||
return join(self.get_build_dir(arch_name), 'Include')
|
||||
|
||||
def link_root(self, arch_name):
|
||||
return join(self.get_build_dir(arch_name), 'android-build')
|
||||
|
||||
def should_build(self, arch):
|
||||
return not Path(self.link_root(arch.arch), self._libpython).is_file()
|
||||
|
||||
def prebuild_arch(self, arch):
|
||||
super().prebuild_arch(arch)
|
||||
self.ctx.python_recipe = self
|
||||
|
||||
def get_recipe_env(self, arch=None, with_flags_in_cc=True):
|
||||
env = super().get_recipe_env(arch)
|
||||
env['HOSTARCH'] = arch.command_prefix
|
||||
|
||||
env['CC'] = arch.get_clang_exe(with_target=True)
|
||||
|
||||
env['PATH'] = (
|
||||
'{hostpython_dir}:{old_path}').format(
|
||||
hostpython_dir=self.get_recipe(
|
||||
'host' + self.name, self.ctx).get_path_to_python(),
|
||||
old_path=env['PATH'])
|
||||
|
||||
env['CFLAGS'] = ' '.join(
|
||||
[
|
||||
'-fPIC',
|
||||
'-DANDROID'
|
||||
]
|
||||
)
|
||||
|
||||
env['LDFLAGS'] = env.get('LDFLAGS', '')
|
||||
if shutil.which('lld') is not None:
|
||||
# Note: The -L. is to fix a bug in python 3.7.
|
||||
# https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=234409
|
||||
env['LDFLAGS'] += ' -L. -fuse-ld=lld'
|
||||
else:
|
||||
warning('lld not found, linking without it. '
|
||||
'Consider installing lld if linker errors occur.')
|
||||
|
||||
return env
|
||||
|
||||
def set_libs_flags(self, env, arch):
|
||||
'''Takes care to properly link libraries with python depending on our
|
||||
requirements and the attribute :attr:`opt_depends`.
|
||||
'''
|
||||
def add_flags(include_flags, link_dirs, link_libs):
|
||||
env['CPPFLAGS'] = env.get('CPPFLAGS', '') + include_flags
|
||||
env['LDFLAGS'] = env.get('LDFLAGS', '') + link_dirs
|
||||
env['LIBS'] = env.get('LIBS', '') + link_libs
|
||||
|
||||
if 'sqlite3' in self.ctx.recipe_build_order:
|
||||
info('Activating flags for sqlite3')
|
||||
recipe = Recipe.get_recipe('sqlite3', self.ctx)
|
||||
add_flags(' -I' + recipe.get_build_dir(arch.arch),
|
||||
' -L' + recipe.get_lib_dir(arch), ' -lsqlite3')
|
||||
|
||||
if 'libffi' in self.ctx.recipe_build_order:
|
||||
info('Activating flags for libffi')
|
||||
recipe = Recipe.get_recipe('libffi', self.ctx)
|
||||
# In order to force the correct linkage for our libffi library, we
|
||||
# set the following variable to point where is our libffi.pc file,
|
||||
# because the python build system uses pkg-config to configure it.
|
||||
env['PKG_CONFIG_PATH'] = recipe.get_build_dir(arch.arch)
|
||||
add_flags(' -I' + ' -I'.join(recipe.get_include_dirs(arch)),
|
||||
' -L' + join(recipe.get_build_dir(arch.arch), '.libs'),
|
||||
' -lffi')
|
||||
|
||||
if 'openssl' in self.ctx.recipe_build_order:
|
||||
info('Activating flags for openssl')
|
||||
recipe = Recipe.get_recipe('openssl', self.ctx)
|
||||
self.configure_args += \
|
||||
('--with-openssl=' + recipe.get_build_dir(arch.arch),)
|
||||
add_flags(recipe.include_flags(arch),
|
||||
recipe.link_dirs_flags(arch), recipe.link_libs_flags())
|
||||
|
||||
for library_name in {'libbz2', 'liblzma'}:
|
||||
if library_name in self.ctx.recipe_build_order:
|
||||
info(f'Activating flags for {library_name}')
|
||||
recipe = Recipe.get_recipe(library_name, self.ctx)
|
||||
add_flags(recipe.get_library_includes(arch),
|
||||
recipe.get_library_ldflags(arch),
|
||||
recipe.get_library_libs_flag())
|
||||
|
||||
# python build system contains hardcoded zlib version which prevents
|
||||
# the build of zlib module, here we search for android's zlib version
|
||||
# and sets the right flags, so python can be build with android's zlib
|
||||
info("Activating flags for android's zlib")
|
||||
zlib_lib_path = arch.ndk_lib_dir_versioned
|
||||
zlib_includes = self.ctx.ndk.sysroot_include_dir
|
||||
zlib_h = join(zlib_includes, 'zlib.h')
|
||||
try:
|
||||
with open(zlib_h) as fileh:
|
||||
zlib_data = fileh.read()
|
||||
except IOError:
|
||||
raise BuildInterruptingException(
|
||||
"Could not determine android's zlib version, no zlib.h ({}) in"
|
||||
" the NDK dir includes".format(zlib_h)
|
||||
)
|
||||
for line in zlib_data.split('\n'):
|
||||
if line.startswith('#define ZLIB_VERSION '):
|
||||
break
|
||||
else:
|
||||
raise BuildInterruptingException(
|
||||
'Could not parse zlib.h...so we cannot find zlib version,'
|
||||
'required by python build,'
|
||||
)
|
||||
env['ZLIB_VERSION'] = line.replace('#define ZLIB_VERSION ', '')
|
||||
add_flags(' -I' + zlib_includes, ' -L' + zlib_lib_path, ' -lz')
|
||||
|
||||
return env
|
||||
|
||||
def build_arch(self, arch):
|
||||
if self.ctx.ndk_api < self.MIN_NDK_API:
|
||||
raise BuildInterruptingException(
|
||||
NDK_API_LOWER_THAN_SUPPORTED_MESSAGE.format(
|
||||
ndk_api=self.ctx.ndk_api, min_ndk_api=self.MIN_NDK_API
|
||||
),
|
||||
)
|
||||
|
||||
recipe_build_dir = self.get_build_dir(arch.arch)
|
||||
|
||||
# Create a subdirectory to actually perform the build
|
||||
build_dir = join(recipe_build_dir, 'android-build')
|
||||
ensure_dir(build_dir)
|
||||
|
||||
# TODO: Get these dynamically, like bpo-30386 does
|
||||
sys_prefix = '/usr/local'
|
||||
sys_exec_prefix = '/usr/local'
|
||||
|
||||
env = self.get_recipe_env(arch)
|
||||
env = self.set_libs_flags(env, arch)
|
||||
|
||||
android_build = sh.Command(
|
||||
join(recipe_build_dir,
|
||||
'config.guess'))().strip()
|
||||
|
||||
with current_directory(build_dir):
|
||||
if not exists('config.status'):
|
||||
shprint(
|
||||
sh.Command(join(recipe_build_dir, 'configure')),
|
||||
*(' '.join(self.configure_args).format(
|
||||
android_host=env['HOSTARCH'],
|
||||
android_build=android_build,
|
||||
python_host_bin=join(self.get_recipe(
|
||||
'host' + self.name, self.ctx
|
||||
).get_path_to_python(), "python3"),
|
||||
prefix=sys_prefix,
|
||||
exec_prefix=sys_exec_prefix)).split(' '),
|
||||
_env=env)
|
||||
|
||||
# Python build does not seem to play well with make -j option from Python 3.11 and onwards
|
||||
# Before losing some time, please check issue
|
||||
# https://github.com/python/cpython/issues/101295 , as the root cause looks similar
|
||||
shprint(
|
||||
sh.make,
|
||||
'all',
|
||||
'INSTSONAME={lib_name}'.format(lib_name=self._libpython),
|
||||
_env=env
|
||||
)
|
||||
|
||||
# TODO: Look into passing the path to pyconfig.h in a
|
||||
# better way, although this is probably acceptable
|
||||
sh.cp('pyconfig.h', join(recipe_build_dir, 'Include'))
|
||||
|
||||
def compile_python_files(self, dir):
|
||||
'''
|
||||
Compile the python files (recursively) for the python files inside
|
||||
a given folder.
|
||||
|
||||
.. note:: python2 compiles the files into extension .pyo, but in
|
||||
python3, and as of Python 3.5, the .pyo filename extension is no
|
||||
longer used...uses .pyc (https://www.python.org/dev/peps/pep-0488)
|
||||
'''
|
||||
args = [self.ctx.hostpython]
|
||||
args += ['-OO', '-m', 'compileall', '-b', '-f', dir]
|
||||
subprocess.call(args)
|
||||
|
||||
def create_python_bundle(self, dirn, arch):
|
||||
"""
|
||||
Create a packaged python bundle in the target directory, by
|
||||
copying all the modules and standard library to the right
|
||||
place.
|
||||
"""
|
||||
# Todo: find a better way to find the build libs folder
|
||||
modules_build_dir = join(
|
||||
self.get_build_dir(arch.arch),
|
||||
'android-build',
|
||||
'build',
|
||||
'lib.linux{}-{}-{}'.format(
|
||||
'2' if self.version[0] == '2' else '',
|
||||
arch.command_prefix.split('-')[0],
|
||||
self.major_minor_version_string
|
||||
))
|
||||
|
||||
# Compile to *.pyc the python modules
|
||||
self.compile_python_files(modules_build_dir)
|
||||
# Compile to *.pyc the standard python library
|
||||
self.compile_python_files(join(self.get_build_dir(arch.arch), 'Lib'))
|
||||
# Compile to *.pyc the other python packages (site-packages)
|
||||
self.compile_python_files(self.ctx.get_python_install_dir(arch.arch))
|
||||
|
||||
# Bundle compiled python modules to a folder
|
||||
modules_dir = join(dirn, 'modules')
|
||||
c_ext = self.compiled_extension
|
||||
ensure_dir(modules_dir)
|
||||
module_filens = (glob.glob(join(modules_build_dir, '*.so')) +
|
||||
glob.glob(join(modules_build_dir, '*' + c_ext)))
|
||||
info("Copy {} files into the bundle".format(len(module_filens)))
|
||||
for filen in module_filens:
|
||||
info(" - copy {}".format(filen))
|
||||
shutil.copy2(filen, modules_dir)
|
||||
|
||||
# zip up the standard library
|
||||
stdlib_zip = join(dirn, 'stdlib.zip')
|
||||
with current_directory(join(self.get_build_dir(arch.arch), 'Lib')):
|
||||
stdlib_filens = list(walk_valid_filens(
|
||||
'.', self.stdlib_dir_blacklist, self.stdlib_filen_blacklist))
|
||||
if 'SOURCE_DATE_EPOCH' in environ:
|
||||
# for reproducible builds
|
||||
stdlib_filens.sort()
|
||||
timestamp = int(environ['SOURCE_DATE_EPOCH'])
|
||||
for filen in stdlib_filens:
|
||||
utime(filen, (timestamp, timestamp))
|
||||
info("Zip {} files into the bundle".format(len(stdlib_filens)))
|
||||
shprint(sh.zip, '-X', stdlib_zip, *stdlib_filens)
|
||||
|
||||
# copy the site-packages into place
|
||||
ensure_dir(join(dirn, 'site-packages'))
|
||||
ensure_dir(self.ctx.get_python_install_dir(arch.arch))
|
||||
# TODO: Improve the API around walking and copying the files
|
||||
with current_directory(self.ctx.get_python_install_dir(arch.arch)):
|
||||
filens = list(walk_valid_filens(
|
||||
'.', self.site_packages_dir_blacklist,
|
||||
self.site_packages_filen_blacklist))
|
||||
info("Copy {} files into the site-packages".format(len(filens)))
|
||||
for filen in filens:
|
||||
info(" - copy {}".format(filen))
|
||||
ensure_dir(join(dirn, 'site-packages', dirname(filen)))
|
||||
shutil.copy2(filen, join(dirn, 'site-packages', filen))
|
||||
|
||||
# copy the python .so files into place
|
||||
python_build_dir = join(self.get_build_dir(arch.arch),
|
||||
'android-build')
|
||||
python_lib_name = 'libpython' + self.link_version
|
||||
shprint(
|
||||
sh.cp,
|
||||
join(python_build_dir, python_lib_name + '.so'),
|
||||
join(self.ctx.bootstrap.dist_dir, 'libs', arch.arch)
|
||||
)
|
||||
|
||||
info('Renaming .so files to reflect cross-compile')
|
||||
self.reduce_object_file_names(join(dirn, 'site-packages'))
|
||||
|
||||
return join(dirn, 'site-packages')
|
||||
|
||||
|
||||
recipe = Python3Recipe()
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
--- Python-3.11.5/Lib/ctypes/util.py 2023-08-24 17:39:18.000000000 +0530
|
||||
+++ Python-3.11.5.mod/Lib/ctypes/util.py 2023-11-18 22:12:17.356160615 +0530
|
||||
@@ -4,7 +4,15 @@
|
||||
import sys
|
||||
|
||||
# find_library(name) returns the pathname of a library, or None.
|
||||
-if os.name == "nt":
|
||||
+
|
||||
+# This patch overrides the find_library to look in the right places on
|
||||
+# Android
|
||||
+if True:
|
||||
+ from android._ctypes_library_finder import find_library as _find_lib
|
||||
+ def find_library(name):
|
||||
+ return _find_lib(name)
|
||||
+
|
||||
+elif os.name == "nt":
|
||||
|
||||
def _get_build_version():
|
||||
"""Return the version of MSVC that was used to build Python.
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py
|
||||
--- a/Lib/ctypes/util.py
|
||||
+++ b/Lib/ctypes/util.py
|
||||
@@ -67,4 +67,11 @@
|
||||
return fname
|
||||
return None
|
||||
|
||||
+# This patch overrides the find_library to look in the right places on
|
||||
+# Android
|
||||
+if True:
|
||||
+ from android._ctypes_library_finder import find_library as _find_lib
|
||||
+ def find_library(name):
|
||||
+ return _find_lib(name)
|
||||
+
|
||||
elif os.name == "posix" and sys.platform == "darwin":
|
||||
12
recipes/python3/patches/py3.7.1_fix-zlib-version.patch
Normal file
12
recipes/python3/patches/py3.7.1_fix-zlib-version.patch
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
--- Python-3.7.1/setup.py.orig 2018-10-20 08:04:19.000000000 +0200
|
||||
+++ Python-3.7.1/setup.py 2019-02-17 00:24:30.715904412 +0100
|
||||
@@ -1410,7 +1410,8 @@ class PyBuildExt(build_ext):
|
||||
if zlib_inc is not None:
|
||||
zlib_h = zlib_inc[0] + '/zlib.h'
|
||||
version = '"0.0.0"'
|
||||
- version_req = '"1.1.3"'
|
||||
+ version_req = '"{}"'.format(
|
||||
+ os.environ.get('ZLIB_VERSION', '1.1.3'))
|
||||
if host_platform == 'darwin' and is_macosx_sdk_path(zlib_h):
|
||||
zlib_h = os.path.join(macosx_sdk_root(), zlib_h[1:])
|
||||
with open(zlib_h) as fp:
|
||||
14
recipes/python3/patches/py3.7.1_fix_cortex_a8.patch
Normal file
14
recipes/python3/patches/py3.7.1_fix_cortex_a8.patch
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
This patch removes --fix-cortex-a8 from the linker flags in order to support linking
|
||||
with lld, as lld does not support this flag (https://github.com/android-ndk/ndk/issues/766).
|
||||
diff --git a/configure b/configure
|
||||
--- a/configure
|
||||
+++ b/configure
|
||||
@@ -5671,7 +5671,7 @@ $as_echo_n "checking for the Android arm ABI... " >&6; }
|
||||
$as_echo "$_arm_arch" >&6; }
|
||||
if test "$_arm_arch" = 7; then
|
||||
BASECFLAGS="${BASECFLAGS} -mfloat-abi=softfp -mfpu=vfpv3-d16"
|
||||
- LDFLAGS="${LDFLAGS} -march=armv7-a -Wl,--fix-cortex-a8"
|
||||
+ LDFLAGS="${LDFLAGS} -march=armv7-a"
|
||||
fi
|
||||
else
|
||||
{ $as_echo "$as_me:${as_lineno-$LINENO}: result: not Android" >&5
|
||||
42
recipes/python3/patches/py3.8.1.patch
Normal file
42
recipes/python3/patches/py3.8.1.patch
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py
|
||||
index 97973bc..053c231 100644
|
||||
--- a/Lib/ctypes/util.py
|
||||
+++ b/Lib/ctypes/util.py
|
||||
@@ -67,6 +67,13 @@ if os.name == "nt":
|
||||
return fname
|
||||
return None
|
||||
|
||||
+# This patch overrides the find_library to look in the right places on
|
||||
+# Android
|
||||
+if True:
|
||||
+ from android._ctypes_library_finder import find_library as _find_lib
|
||||
+ def find_library(name):
|
||||
+ return _find_lib(name)
|
||||
+
|
||||
elif os.name == "posix" and sys.platform == "darwin":
|
||||
from ctypes.macholib.dyld import dyld_find as _dyld_find
|
||||
def find_library(name):
|
||||
diff --git a/configure b/configure
|
||||
index 0914e24..dd00812 100755
|
||||
--- a/configure
|
||||
+++ b/configure
|
||||
@@ -18673,4 +18673,3 @@ if test "$Py_OPT" = 'false' -a "$Py_DEBUG" != 'true'; then
|
||||
echo "" >&6
|
||||
echo "" >&6
|
||||
fi
|
||||
-
|
||||
diff --git a/setup.py b/setup.py
|
||||
index 20d7f35..af15cc2 100644
|
||||
--- a/setup.py
|
||||
+++ b/setup.py
|
||||
@@ -1501,7 +1501,9 @@ class PyBuildExt(build_ext):
|
||||
if zlib_inc is not None:
|
||||
zlib_h = zlib_inc[0] + '/zlib.h'
|
||||
version = '"0.0.0"'
|
||||
- version_req = '"1.1.3"'
|
||||
+ # version_req = '"1.1.3"'
|
||||
+ version_req = '"{}"'.format(
|
||||
+ os.environ.get('ZLIB_VERSION', '1.1.3'))
|
||||
if MACOS and is_macosx_sdk_path(zlib_h):
|
||||
zlib_h = os.path.join(macosx_sdk_root(), zlib_h[1:])
|
||||
with open(zlib_h) as fp:
|
||||
15
recipes/python3/patches/py3.8.1_fix_cortex_a8.patch
Normal file
15
recipes/python3/patches/py3.8.1_fix_cortex_a8.patch
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
This patch removes --fix-cortex-a8 from the linker flags in order to support linking
|
||||
with lld, as lld does not support this flag (https://github.com/android-ndk/ndk/issues/766).
|
||||
diff --git a/configure b/configure
|
||||
index 0914e24..7517168 100755
|
||||
--- a/configure
|
||||
+++ b/configure
|
||||
@@ -5642,7 +5642,7 @@ $as_echo_n "checking for the Android arm ABI... " >&6; }
|
||||
$as_echo "$_arm_arch" >&6; }
|
||||
if test "$_arm_arch" = 7; then
|
||||
BASECFLAGS="${BASECFLAGS} -mfloat-abi=softfp -mfpu=vfpv3-d16"
|
||||
- LDFLAGS="${LDFLAGS} -march=armv7-a -Wl,--fix-cortex-a8"
|
||||
+ LDFLAGS="${LDFLAGS} -march=armv7-a"
|
||||
fi
|
||||
else
|
||||
{ $as_echo "$as_me:${as_lineno-$LINENO}: result: not Android" >&5
|
||||
13
recipes/python3/patches/pyconfig_detection.patch
Normal file
13
recipes/python3/patches/pyconfig_detection.patch
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
diff -Nru Python-3.8.2/Lib/site.py Python-3.8.2-new/Lib/site.py
|
||||
--- Python-3.8.2/Lib/site.py 2020-04-28 12:48:38.000000000 -0700
|
||||
+++ Python-3.8.2-new/Lib/site.py 2020-04-28 12:52:46.000000000 -0700
|
||||
@@ -487,7 +487,8 @@
|
||||
if key == 'include-system-site-packages':
|
||||
system_site = value.lower()
|
||||
elif key == 'home':
|
||||
- sys._home = value
|
||||
+ # this is breaking pyconfig.h path detection with venv
|
||||
+ print('Ignoring "sys._home = value" override')
|
||||
|
||||
sys.prefix = sys.exec_prefix = site_prefix
|
||||
|
||||
13
recipes/python3/patches/reproducible-buildinfo.diff
Normal file
13
recipes/python3/patches/reproducible-buildinfo.diff
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# DP: Build getbuildinfo.o with DATE/TIME values when defined
|
||||
|
||||
--- a/Makefile.pre.in
|
||||
+++ b/Makefile.pre.in
|
||||
@@ -785,6 +785,8 @@ Modules/getbuildinfo.o: $(PARSER_OBJS) \
|
||||
-DGITVERSION="\"`LC_ALL=C $(GITVERSION)`\"" \
|
||||
-DGITTAG="\"`LC_ALL=C $(GITTAG)`\"" \
|
||||
-DGITBRANCH="\"`LC_ALL=C $(GITBRANCH)`\"" \
|
||||
+ $(if $(BUILD_DATE),-DDATE='"$(BUILD_DATE)"') \
|
||||
+ $(if $(BUILD_TIME),-DTIME='"$(BUILD_TIME)"') \
|
||||
-o $@ $(srcdir)/Modules/getbuildinfo.c
|
||||
|
||||
Modules/getpath.o: $(srcdir)/Modules/getpath.c Makefile
|
||||
11
recipes/sqlite3/Android.mk
Normal file
11
recipes/sqlite3/Android.mk
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
LOCAL_PATH := $(call my-dir)/..
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
|
||||
LOCAL_SRC_FILES := sqlite3.c
|
||||
|
||||
LOCAL_MODULE := sqlite3
|
||||
|
||||
LOCAL_CFLAGS := -DSQLITE_ENABLE_FTS4 -D_FILE_OFFSET_BITS=32 -DSQLITE_ENABLE_JSON1
|
||||
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue