Compare commits

...

61 commits
1.6.1 ... main

Author SHA1 Message Date
Mark Qvist
5d7a83a39c Fixed regression in service RPC signature. Added service RPC connection re-init if connection drops unexpectedly. 2025-11-08 20:10:26 +01:00
Mark Qvist
a6618da229 Fixed missing location permission check for scanning and pairing BLE RNodes 2025-11-08 13:59:23 +01:00
Mark Qvist
4ee9623033 Request microphone permission on voice calls enable 2025-11-08 00:14:57 +01:00
Mark Qvist
0c965d9259 Ensure RNS exit handlers are executed on service shutdown, in case OS event is not sent. Fixes #90. 2025-11-07 23:15:23 +01:00
Mark Qvist
dce43bdcfc Updated version 2025-11-07 10:43:26 +01:00
Mark Qvist
92635ea1f2 Fixed missing telephone announce handler in frontend/service proxy on Android 2025-11-07 10:24:26 +01:00
Mark Qvist
3754f3bfa9 Updated LXST dependency 2025-11-06 22:52:35 +01:00
Mark Qvist
0112f85d34 Added low-latency output mode configuration option for LXST voice calls 2025-11-06 22:46:22 +01:00
Mark Qvist
829ccc6df9 Cleanup 2025-11-06 17:07:48 +01:00
Mark Qvist
6b09c6b262 Added voice call notifications 2025-11-06 17:07:00 +01:00
Mark Qvist
cf0d64a746 Implemented voice call support on Android 2025-11-06 12:11:54 +01:00
Mark Qvist
7d0c9e8c4d Enabled LXST voice calls on Android 2025-11-06 09:17:12 +01:00
Mark Qvist
e5dfa81b0a Implemented Kivy/SDL2 workaround for slow window resize updates on Linux 2025-11-04 15:26:00 +01:00
Mark Qvist
00b9517458 Implemented window size and position persistence 2025-11-04 14:19:46 +01:00
Mark Qvist
88e8db6118 Updated required LXMF to 0.9.1 2025-11-03 22:35:22 +01:00
Mark Qvist
ef53318d29 Fixed potential race configuration in first config init 2025-11-03 21:20:53 +01:00
Mark Qvist
017a1c1b28 Cleanup 2025-11-03 20:16:41 +01:00
Mark Qvist
8cd00f29d9 Updated Linux install instructions 2025-11-03 20:10:25 +01:00
Mark Qvist
c94e177405 Updated makefile 2025-11-03 20:10:11 +01:00
Mark Qvist
14436bce08 Go home Windows, you're drunk. Only apply UI scaling factor to window size on Linux. 2025-11-03 20:09:54 +01:00
Mark Qvist
36af5d805d Updated Linux install instructions 2025-11-03 16:29:04 +01:00
Mark Qvist
4daf4ef40a Updated Linux install instructions 2025-11-03 16:27:18 +01:00
Mark Qvist
8b191784f3 Updated Raspberry Pi install instructions 2025-11-03 16:20:39 +01:00
Mark Qvist
1bb61c1009 Added Able to credits 2025-11-03 15:06:08 +01:00
Mark Qvist
b7e24d8813 Updated build code 2025-11-03 15:05:55 +01:00
Mark Qvist
b3057dabf8 Cleanup 2025-11-03 14:31:54 +01:00
Mark Qvist
f3b0c71c7b Added Weave interface to connectivity settings 2025-11-03 14:25:41 +01:00
Mark Qvist
2fe1527ba2 Consistent pane close event handling 2025-11-03 14:09:49 +01:00
Mark Qvist
fc9e1bf86f Added call confirm dialog 2025-11-03 13:37:48 +01:00
Mark Qvist
ef0aed51a2 Cleanup 2025-11-02 18:35:35 +01:00
Mark Qvist
a2c2de49fe Fixed missing none check 2025-11-02 03:03:28 +01:00
Mark Qvist
9a11bfded2 Various UI improvements 2025-11-02 02:57:51 +01:00
Mark Qvist
2eec1d0a50 Updated dependencies 2025-11-02 00:39:11 +01:00
Mark Qvist
9bd7a5970c Implemented getting and displaying backend service log on Android 2025-11-02 00:27:57 +01:00
Mark Qvist
0a1be10411 Implemented profile backup and export. Implemented window size following UI scaling factor. 2025-11-02 00:00:38 +01:00
Mark Qvist
9a3cda077c Implemented workaround for Kivy/SDL bug where on_resume is not dispatched on Android 2025-11-01 23:33:49 +01:00
Mark Qvist
42d859f96e Improved audio processing for voice optimizaiton. Fixes #82. 2025-11-01 00:03:24 +01:00
Mark Qvist
6802161b4f Updates for LXMF 0.9.0 compatibility 2025-10-31 22:28:08 +01:00
Mark Qvist
2758b571ff WeaveInterface compatibility on Android 2025-10-29 15:45:25 +01:00
Mark Qvist
9b6a51a03e Use local version of able 2025-10-29 12:54:59 +01:00
Mark Qvist
2e44d49d6b Updated gitignore 2025-10-29 12:51:50 +01:00
Mark Qvist
d8581c981c Refactored conversation list 2025-10-29 02:22:23 +01:00
Mark Qvist
165f480d7e Conversation list refactoring 2025-10-29 02:08:27 +01:00
Mark Qvist
3f0d91ecdb Fixed Android BLE stack re-connection hang for RNodeInterface 2025-10-28 12:33:06 +01:00
Mark Qvist
dfac5cbd4a Updated dependencies 2025-10-28 02:29:25 +01:00
Mark Qvist
88010b76fc Updated build configuration 2025-10-28 02:24:11 +01:00
Mark Qvist
4b3e3d9e4e Updated gitignore 2025-10-28 02:23:56 +01:00
Mark Qvist
6a133842db Updated build recipes 2025-10-28 02:22:25 +01:00
Mark Qvist
846e7d7687 Updated makefile 2025-10-27 18:10:43 +01:00
Mark Qvist
26e115765f Updated dependencies 2025-10-27 12:28:13 +01:00
Mark Qvist
354fb08297 Cleanup 2025-07-14 21:35:58 +02:00
Mark Qvist
f0ec8fde42 Updated bluetooth scanning text 2025-07-14 20:37:58 +02:00
Mark Qvist
73601ebe1e Added in-app BLE scanning and pairing 2025-07-14 16:06:50 +02:00
Mark Qvist
0d2f7b25a3 Added service restart 2025-07-14 00:11:21 +02:00
Mark Qvist
aee675d38b Fixed missing widget hide 2025-07-13 14:58:24 +02:00
Mark Qvist
e6ef41815c Updated dependencies 2025-07-13 14:58:16 +02:00
Mark Qvist
2d7b5d2527 Merge branch 'main' of github.com:markqvist/Sideband 2025-05-18 18:03:53 +02:00
Mark Qvist
cd7562390c Fixed incorrect widget ID. Closes #79. 2025-05-18 18:03:25 +02:00
markqvist
aaed27d4ac
Update README.md 2025-05-17 20:37:55 +02:00
Mark Qvist
3c03070b6e Added funding 2025-05-17 10:26:29 +02:00
Mark Qvist
668dd48cee Updated dependencies 2025-05-16 12:50:38 +02:00
118 changed files with 8758 additions and 2366 deletions

3
.gitignore vendored
View file

@ -34,3 +34,6 @@ docs/build
sideband*.egg-info
sbapp*.egg-info
LXST
environment
archived_build_tools
.gradle

3
FUNDING.yml Normal file
View file

@ -0,0 +1,3 @@
liberapay: Reticulum
ko_fi: markqvist
custom: "https://unsigned.io/donate"

View file

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

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

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

@ -0,0 +1,103 @@
Changelog
=========
1.0.16
------
* Added `autoconnect` parameter to connection methods
`#45 <https://github.com/b3b/able/issues/45>`_
1.0.15
------
* Changing the wheel name to avoid installing a package from cache
`#40 <https://github.com/b3b/able/issues/40>`_
1.0.14
------
* Added event handler for bluetooth adapter state change
`#39 <https://github.com/b3b/able/pull/39>`_ by `robgar2001 <https://github.com/robgar2001>`_
* Removal of deprecated `convert_path` from setup script
1.0.13
------
* Fixed build failure when pip isolated environment is used `#38 <https://github.com/b3b/able/issues/38>`_
1.0.12
------
* Fixed crash on API level 31 (Android 12) `#37 <https://github.com/b3b/able/issues/37>`_
* Added new optional `BluetoothDispatcher` parameter to specifiy required permissions: `runtime_permissions`.
Runtime permissions that are required by by default:
ACCESS_FINE_LOCATION, BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE
* Changed `able.require_bluetooth_enabled` behavior: first asks for runtime permissions
and if permissions are granted then offers to enable the adapter
* `require_runtime_permissions` decorator deprecated
1.0.11
------
* Improved logging of reconnection management
`#33 <https://github.com/b3b/able/pull/33>`_ by `robgar2001 <https://github.com/robgar2001>`_
1.0.10
------
* Fixed build failure after AAB support was added to python-for-android
1.0.9
-----
* Switched from deprecated scanning method `BluetoothAdapter.startLeScan` to `BluetoothLeScanner.startScan`
* Added support for BLE scanning settings: `able.scan_settings` module
* Added support for BLE scanning filters: `able.filters` module
1.0.8
-----
* Added support to use `able` in Android services
* Added decorators:
- `able.require_bluetooth_enabled`: to call `BluetoothDispatcher` method when bluetooth adapter becomes ready
- `able.require_runtime_permissions`: to call `BluetoothDispatcher` method when location runtime permission is granted
1.0.7
-----
* Added `able.advertising`: module to perform BLE advertise operations
* Added property to get and set Bluetoth adapter name
1.0.6
-----
* Fixed `TypeError` exception on `BluetoothDispatcher.enable_notifications`
1.0.5
-----
* Added `BluetoothDispatcher.bonded_devices` property: list of paired BLE devices
1.0.4
-----
* Fixed sending string data with `write_characteristic` function
1.0.3
-----
* Changed package version generation:
- Version is set during the build, from the git tag
- Development (git master) version is always "0.0.0"
* Added ATT MTU changing method and callback
* Added MTU changing example
* Fixed:
- set `BluetoothDispatcher.gatt` attribute in GATT connection handler,
to avoid possible `on_connection_state_change()` call before the `gatt` attribute is set

22
libs/able/LICENSE Normal file
View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2017 b3b
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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

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

View file

@ -0,0 +1,84 @@
from enum import IntEnum
from able.structures import Advertisement, Services
from able.version import __version__ # noqa
from kivy.utils import platform
__all__ = (
"Advertisement",
"BluetoothDispatcher",
"Services",
)
# constants
GATT_SUCCESS = 0 #: GATT operation completed successfully
STATE_CONNECTED = 2 #: The profile is in connected state
STATE_DISCONNECTED = 0 #: The profile is in disconnected state
class AdapterState(IntEnum):
"""Bluetooth adapter state constants.
https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#STATE_OFF
"""
OFF = 10 #: Adapter is off
TURNING_ON = 11 #: Adapter is turning on
ON = 12 #: Adapter is on
TURNING_OFF = 13 #: Adapter is turning off
class WriteType(IntEnum):
"""GATT characteristic write types constants."""
DEFAULT = (
2 #: Write characteristic, requesting acknowledgement by the remote device
)
NO_RESPONSE = (
1 #: Write characteristic without requiring a response by the remote device
)
SIGNED = 4 #: Write characteristic including authentication signature
if platform == "android":
from able.android.dispatcher import BluetoothDispatcher
else:
# mock android and PyJNIus modules usage
import sys
from unittest.mock import Mock
sys.modules["android"] = Mock()
sys.modules["android.permissions"] = Mock()
jnius = Mock()
class mocked_autoclass(Mock):
def __call__(self, *args, **kwargs):
mock = Mock()
mock.__repr__ = lambda s: f"jnius.autoclass('{args[0]}')"
mock.SDK_INT = 255
return mock
jnius.autoclass = mocked_autoclass()
sys.modules["jnius"] = jnius
from able.dispatcher import BluetoothDispatcherBase
class BluetoothDispatcher(BluetoothDispatcherBase):
"""Bluetooth Low Energy interface
:param queue_timeout: BLE operations queue timeout
:param enable_ble_code: request code to identify activity that alows
user to turn on Bluetooth adapter
:param runtime_permissions: overridden list of
:py:mod:`permissions <able.permissions>`
to be requested on runtime.
"""
from able.adapter import require_bluetooth_enabled
from able.permissions import Permission
def require_runtime_permissions(method):
"""Deprecated decorator, left for backwards compatibility."""
return method

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

@ -0,0 +1,179 @@
from dataclasses import dataclass, field
from functools import partial, wraps
from typing import Optional
from android import activity
from android.permissions import (
check_permission,
request_permissions,
)
from jnius import autoclass
from kivy.logger import Logger
Activity = autoclass("android.app.Activity")
def require_bluetooth_enabled(method):
"""Decorator to call `BluetoothDispatcher` method
when runtime permissions are granted
and Bluetooth adapter becomes ready.
Decorator launches system activities that allows the user
to grant runtime permissions and turn on Bluetooth,
if Bluetooth is not enabled.
"""
@wraps(method)
def wrapper(self, *args, **kwargs):
manager = AdapterManager.get_attached_manager(self)
if manager:
return manager.execute(partial(method, self, *args, **kwargs))
return None
return wrapper
def set_adapter_failure_rollback(handler):
"""Decorator to launch handler
if permissions are not granted or adapter is not enabled."""
def inner(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
manager = AdapterManager.get_attached_manager(self)
if manager:
manager.rollback_handlers.append(partial(handler, self))
return func(self, *args, **kwargs)
return None
return wrapper
return inner
@dataclass
class AdapterManager:
"""
Class for managing the execution of operations
that require the BLE adapter.
Operations are deferred until runtime permissions are granted
and the BLE adapter is enabled.
"""
ble: "org.able.BLE"
enable_ble_code: str
runtime_permissions: list
operations: list = field(default_factory=list)
rollback_handlers: list = field(default_factory=list)
is_permissions_granted: bool = False
is_permissions_requested: bool = False
is_adapter_requested: bool = False
@property
def adapter(self) -> Optional["android.bluetooth.BluetoothAdapter"]:
if self.has_permissions:
adapter = self.ble.mBluetoothAdapter
if adapter and adapter.isEnabled():
return adapter
return None
@property
def has_permissions(self):
if not self.is_permissions_granted:
self.is_permissions_granted = self.check_permissions()
return self.is_permissions_granted
@property
def is_service_context(self):
return not activity._activity
def __post_init__(self):
if self.is_service_context:
self.is_permissions_granted = True
else:
activity.bind(on_activity_result=self.on_activity_result)
@classmethod
def get_attached_manager(cls, instance):
manager = getattr(instance, "_adapter_manager", None)
if not manager:
Logger.error("BLE adapter manager is not installed")
return manager
def install(self, instance):
setattr(instance, "_adapter_manager", self)
def check_permissions(self):
return all(
[check_permission(permission) for permission in self.runtime_permissions]
)
def request_permissions(self):
if self.is_permissions_requested:
return
self.is_permissions_requested = True
if not self.is_service_context:
Logger.debug("Request runtime permissions")
request_permissions(
self.runtime_permissions,
self.on_runtime_permissions,
)
else:
Logger.error("Required permissions are not granted for service")
def request_adapter(self):
if self.is_adapter_requested:
return
self.is_adapter_requested = True
self.ble.getAdapter(self.enable_ble_code)
def rollback(self):
self._execute_operations(self.rollback_handlers)
def execute(self, operation):
if self.adapter:
# execute immediately, if adapter is enabled
return operation()
self.operations.append(operation)
self.execute_operations()
def execute_operations(self):
if self.has_permissions:
if self.adapter:
self._execute_operations(self.operations)
else:
self.request_adapter()
else:
self.request_permissions()
def _execute_operations(self, operations):
self.operations = []
self.rollback_handlers = []
for operation in operations:
try:
operation()
except Exception as exc:
Logger.exception(exc)
def on_runtime_permissions(self, permissions, grant_results):
granted = all(grant_results)
self.is_permissions_granted = granted
self.is_permissions_requested = False # allow future invocations
if granted:
Logger.debug("Required permissions are granted")
self.execute_operations()
else:
Logger.error("Required permissions are not granted")
self.rollback()
def on_activity_result(self, requestCode, resultCode, intent):
if requestCode == self.enable_ble_code:
enabled = resultCode == Activity.RESULT_OK
self.is_adapter_requested = False # allow future invocations
if enabled:
Logger.debug("BLE adapter is enabled")
self.execute_operations()
else:
Logger.error("BLE adapter is not enabled")
self.rollback()

View file

@ -0,0 +1,330 @@
"""BLE advertise operations."""
from abc import abstractmethod
from dataclasses import dataclass
from enum import IntEnum
from typing import List, Optional, Union
from jnius import JavaException, autoclass
from kivy.event import EventDispatcher
from able.android.dispatcher import BluetoothDispatcher
from able.android.jni import PythonBluetoothAdvertiser
from able.utils import force_convertible_to_java_array
AdvertiseDataBuilder = autoclass('android.bluetooth.le.AdvertiseData$Builder')
AdvertisingSet = autoclass('android.bluetooth.le.AdvertisingSet')
AdvertisingSetParametersBuilder = autoclass('android.bluetooth.le.AdvertisingSetParameters$Builder')
AndroidAdvertiseData = autoclass('android.bluetooth.le.AdvertiseData')
BluetoothLeAdvertiser = autoclass('android.bluetooth.le.BluetoothLeAdvertiser')
ParcelUuid = autoclass('android.os.ParcelUuid')
BLEAdvertiser = autoclass('org.able.BLEAdvertiser')
class Interval(IntEnum):
"""Advertising interval constants.
https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters#INTERVAL_HIGH
"""
MIN = 160 #: Minimum value for advertising interval, around every 100ms
MEDIUM = 400 #: Advertise on medium frequency, around every 250ms
HIGH = 1600 #: Advertise on low frequency, around every 1000ms
MAX = 16777215 #: Maximum value for advertising interval
class TXPower(IntEnum):
"""Advertising transmission (TX) power level constants.
https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters#TX_POWER_HIGH
"""
MIN = -127 #: Minimum value for TX power
ULTRA_LOW = -21 #: Advertise using the lowest TX power level
LOW = -15 #: Advertise using the low TX power level
MEDIUM = -7 #: Advertise using the medium TX power level
MAX = 1 #: Maximum value for TX power
class Status:
"""Advertising operation status constants.
https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetCallback#constants
"""
SUCCESS = 0
DATA_TOO_LARGE = 1
TOO_MANY_ADVERTISERS = 2
ALREADY_STARTED = 3
INTERNAL_ERROR = 4
FEATURE_UNSUPPORTED = 5
@dataclass
class ADStructure:
@abstractmethod
def add_payload(self, builder: AdvertiseDataBuilder):
pass
class DeviceName(ADStructure):
"""Include device name (complete local name) in advertise packet."""
def add_payload(self, builder):
builder.setIncludeDeviceName(True)
class TXPowerLevel(ADStructure):
"""Include transmission power level in the advertise packet."""
def add_payload(self, builder):
builder.setIncludeTxPowerLevel(True)
@dataclass
class ServiceUUID(ADStructure):
"""Service UUID to advertise.
:param uid: UUID to be advertised
"""
uid: str
def add_payload(self, builder):
builder.addServiceUuid(
ParcelUuid.fromString(self.uid)
)
@dataclass
class ServiceData(ADStructure):
"""Service data to advertise.
:param uid: UUID of the service the data is associated with
:param data: Service data
"""
uid: str
data: Union[list, tuple, bytes, bytearray]
def add_payload(self, builder):
builder.addServiceData(
ParcelUuid.fromString(self.uid),
force_convertible_to_java_array(self.data)
)
@dataclass
class ManufacturerData(ADStructure):
"""Manufacturer specific data to advertise.
:param id: Manufacturer ID
:param data: Manufacturer specific data
"""
id: int
data: Union[list, tuple, bytes, bytearray]
def add_payload(self, builder):
builder.addManufacturerData(
self.id,
force_convertible_to_java_array(self.data)
)
class AdvertiseData:
"""Builder for data payload to be advertised.
:param payload: List of AD structures to include in advertisement
>>> AdvertiseData(DeviceName(), ManufacturerData(10, b'specific data'))
[DeviceName(), ManufacturerData(id=10, data=b'specific data')]
"""
def __init__(self, *payload: List[ADStructure]):
self.payload = payload
self.data = self.build()
def __repr__(self):
sections = ", ".join(repr(ad) for ad in self.payload)
return f"[{sections}]"
def build(self) -> AndroidAdvertiseData:
builder = AdvertiseDataBuilder()
for ad in self.payload:
ad.add_payload(builder)
return builder.build()
class Advertiser(EventDispatcher):
"""Base class for BLE advertise operations.
:param ble: BLE interface instance
:param data: Advertisement data to be broadcasted
:param scan_data: Scan response associated with the advertisement data
:param interval: Advertising interval
`<https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters.Builder#setInterval(int)>`_
:param tx_power: Transmission power level
`<https://developer.android.com/reference/android/bluetooth/le/AdvertisingSetParameters.Builder#setTxPowerLevel(int)>`_
>>> Advertiser(
... ble=BluetoothDispatcher(),
... data=AdvertiseData(DeviceName()),
... scan_data=AdvertiseData(TXPowerLevel()),
... interval=Interval.MIN,
... tx_power=TXPower.MAX
... ) #doctest: +ELLIPSIS
<able.advertising.Advertiser object at 0x...>
"""
__events__ = (
'on_advertising_started',
'on_advertising_stopped',
'on_advertising_enabled',
'on_advertising_data_set',
'on_scan_response_data_set',
'on_advertising_parameters_updated',
'on_advertising_set_changed',
)
def __init__(
self,
ble: BluetoothDispatcher,
data: AdvertiseData = None,
scan_data: AdvertiseData = None,
interval: int = Interval.HIGH,
tx_power: int = TXPower.MEDIUM,
):
self._ble = ble
self._data = data
self._scan_data = scan_data
self._interval = interval
self._tx_power = tx_power
self._events_interface = PythonBluetoothAdvertiser(self)
self._advertiser = BLEAdvertiser(self._events_interface)
self._callback_set = self._advertiser.mCallbackSet
self._advertising_set = None
@property
def data(self):
"""
:setter: Update advertising data
:type: Optional[AdvertiseData]
"""
return self._data
@data.setter
def data(self, value):
self._data = value
self._update_advertising_set()
@property
def scan_data(self):
"""
:setter: Update the scan response
:type: Optional[AdvertiseData]
"""
return self._scan_data
@scan_data.setter
def scan_data(self, value):
self._scan_data = value
self._update_advertising_set()
@property
def interval(self):
"""
:setter: Update the advertising interval
:type: int
"""
return self._interval
@interval.setter
def interval(self, value):
self._interval = value
self._update_advertising_set()
@property
def tx_power(self):
"""
:setter: Update the transmission power level
:type: int
"""
return self._tx_power
@tx_power.setter
def tx_power(self, value):
self._tx_power = value
self._update_advertising_set()
@property
def bluetooth_le_advertiser(self) -> Optional[BluetoothLeAdvertiser]:
adapter = self._ble.adapter
return adapter and adapter.getBluetoothLeAdvertiser()
@property
def parameters(self) -> AdvertisingSetParametersBuilder:
builder = AdvertisingSetParametersBuilder()
builder.setLegacyMode(True) \
.setConnectable(False) \
.setScannable(True) \
.setInterval(self._interval) \
.setTxPowerLevel(self._tx_power)
return builder.build()
def start(self):
"""Start advertising.
Start a system activity that allows the user to turn on Bluetooth if Bluetooth is not enabled.
"""
if not self._advertising_set:
self._ble._start_advertising(self)
def stop(self):
"""Stop advertising."""
advertiser = self.bluetooth_le_advertiser
if advertiser:
advertiser.stopAdvertisingSet(self._callback_set)
def on_advertising_started(self, advertising_set: AdvertisingSet, tx_power: int, status: Status):
"""Handler for advertising start operation (onAdvertisingSetStarted).
"""
def on_advertising_stopped(self, advertising_set: AdvertisingSet):
"""Handler for advertising stop operation (onAdvertisingSetStopped)."""
def on_advertising_enabled(self, advertising_set: AdvertisingSet, enable: bool, status: Status):
"""Handler for advertising enable/disable operation (onAdvertisingEnabled)."""
def on_advertising_data_set(self, advertising_set: AdvertisingSet, status: Status):
"""Handler for data set operation (onAdvertisingDataSet)."""
def on_scan_response_data_set(self, advertising_set: AdvertisingSet, status: Status):
"""Handler for scan response data set operation (onScanResponseDataSet)."""
def on_advertising_parameters_updated(self, advertising_set: AdvertisingSet, tx_power: int, status: Status):
"""Handler for parameters set operation (onAdvertisingParametersUpdated)."""
def on_advertising_set_changed(self, advertising_set):
self._advertising_set = advertising_set
def _start(self):
advertiser = self.bluetooth_le_advertiser
if advertiser:
self._callback_set = self._advertiser.createCallback()
try:
advertiser.startAdvertisingSet(
self.parameters,
self._data and self._data.data,
self._scan_data and self._scan_data.data,
None, # periodicParameters
None, # periodicData
self._callback_set
)
except JavaException as exc:
if exc.classname == 'java.lang.IllegalArgumentException' and \
exc.innermessage.endswith('data too big'):
self.dispatch('on_advertising_started', None, 0, Status.DATA_TOO_LARGE)
raise
def _update_advertising_set(self):
advertising_set = self._advertising_set
if advertising_set:
advertising_set.setAdvertisingParameters(self.parameters)
advertising_set.setScanResponseData(self._scan_data and self._scan_data.data)
advertising_set.setAdvertisingData(self._data and self._data.data)

View file

View file

@ -0,0 +1,105 @@
from jnius import JavaException, autoclass
from kivy.logger import Logger
from able.adapter import (
AdapterManager,
require_bluetooth_enabled,
set_adapter_failure_rollback,
)
from able.android.jni import PythonBluetooth
from able.dispatcher import BluetoothDispatcherBase
from able.scan_settings import ScanSettingsBuilder
ArrayList = autoclass("java.util.ArrayList")
try:
BLE = autoclass("org.able.BLE")
except:
Logger.error(
"able_recipe: Failed to load Java class org.able.BLE. Possible build error."
)
raise
else:
Logger.info("able_recipe: org.able.BLE Java class loaded")
BluetoothAdapter = autoclass("android.bluetooth.BluetoothAdapter")
BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice")
BluetoothGattDescriptor = autoclass("android.bluetooth.BluetoothGattDescriptor")
ENABLE_NOTIFICATION_VALUE = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
ENABLE_INDICATION_VALUE = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
DISABLE_NOTIFICATION_VALUE = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
class BluetoothDispatcher(BluetoothDispatcherBase):
@property
@require_bluetooth_enabled
def adapter(self):
return AdapterManager.get_attached_manager(self).adapter
@property
def bonded_devices(self):
ble_types = (BluetoothDevice.DEVICE_TYPE_LE, BluetoothDevice.DEVICE_TYPE_DUAL)
adapter = self.adapter
devices = adapter.getBondedDevices().toArray() if adapter else []
return [dev for dev in devices if dev.getType() in ble_types]
def _set_ble_interface(self):
self._events_interface = PythonBluetooth(self)
self._ble = BLE(self._events_interface)
@set_adapter_failure_rollback(
lambda self: self.dispatch("on_scan_started", success=False)
)
@require_bluetooth_enabled
def start_scan(self, filters=None, settings=None):
filters_array = ArrayList()
for f in filters or []:
filters_array.add(f.build())
if not settings:
settings = ScanSettingsBuilder()
try:
settings = settings.build()
except AttributeError:
pass
self._ble.startScan(self.enable_ble_code, filters_array, settings)
def stop_scan(self):
self._ble.stopScan()
@require_bluetooth_enabled
def connect_by_device_address(self, address: str, autoconnect: bool = False):
address = address.upper()
if not BluetoothAdapter.checkBluetoothAddress(address):
raise ValueError(f"{address} is not a valid Bluetooth address")
adapter = self.adapter
if adapter:
self.connect_gatt(adapter.getRemoteDevice(address), autoconnect)
@require_bluetooth_enabled
def enable_notifications(self, characteristic, enable=True, indication=False):
if not self.gatt.setCharacteristicNotification(characteristic, enable):
return False
if not enable:
# DISABLE_NOTIFICAITON_VALUE is for disabling
# both notifications and indications
descriptor_value = DISABLE_NOTIFICATION_VALUE
elif indication:
descriptor_value = ENABLE_INDICATION_VALUE
else:
descriptor_value = ENABLE_NOTIFICATION_VALUE
for descriptor in characteristic.getDescriptors().toArray():
self.write_descriptor(descriptor, descriptor_value)
return True
@require_bluetooth_enabled
def _start_advertising(self, advertiser):
advertiser._start()
@require_bluetooth_enabled
def _set_name(self, value):
adapter = self.adapter
if adapter:
self.adapter.setName(value)

View file

@ -0,0 +1,151 @@
from able import GATT_SUCCESS
from able.structures import Advertisement, Services
from jnius import PythonJavaClass, java_method
from kivy.logger import Logger
class PythonBluetooth(PythonJavaClass):
__javainterfaces__ = ['org.able.PythonBluetooth']
__javacontext__ = 'app'
def __init__(self, dispatcher):
super(PythonBluetooth, self).__init__()
self.dispatcher = dispatcher
@java_method('(Ljava/lang/String;)V')
def on_error(self, msg):
Logger.debug("on_error")
self.dispatcher.dispatch('on_error', msg)
@java_method('(Landroid/bluetooth/le/ScanResult;)V')
def on_scan_result(self, result):
device = result.getDevice() # type: android.bluetooth.BluetoothDevice
record = result.getScanRecord() # type: android.bluetooth.le.ScanRecord
if record:
self.dispatcher.dispatch(
'on_device',
device,
result.getRssi(),
Advertisement(record.getBytes())
)
else:
Logger.warning(
"Scan result for device without the scan record: %s",
device
)
@java_method('(Z)V')
def on_scan_started(self, success):
Logger.debug("on_scan_started")
self.dispatcher.dispatch('on_scan_started', success)
@java_method('()V')
def on_scan_completed(self):
Logger.debug("on_scan_completed")
self.dispatcher.dispatch('on_scan_completed')
@java_method('(II)V')
def on_connection_state_change(self, status, state):
Logger.debug("on_connection_state_change status={} state: {}".format(
status, state))
self.dispatcher.dispatch('on_connection_state_change', status, state)
@java_method('(I)V')
def on_bluetooth_adapter_state_change(self, state):
Logger.debug("on_bluetooth_adapter_state_change state: {}".format(state))
self.dispatcher.dispatch('on_bluetooth_adapter_state_change', state)
@java_method('(ILjava/util/List;)V')
def on_services(self, status, services):
services_dict = Services()
if status == GATT_SUCCESS:
for service in services.toArray():
service_uuid = service.getUuid().toString()
Logger.debug("Service discovered: {}".format(service_uuid))
services_dict[service_uuid] = {}
for c in service.getCharacteristics().toArray():
characteristic_uuid = c.getUuid().toString()
Logger.debug("Characteristic discovered: {}".format(
characteristic_uuid))
services_dict[service_uuid][characteristic_uuid] = c
self.dispatcher.dispatch('on_services', status, services_dict)
@java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;)V')
def on_characteristic_changed(self, characteristic):
# uuid = characteristic.getUuid().toString()
self.dispatcher.dispatch('on_characteristic_changed', characteristic)
@java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;I)V')
def on_characteristic_read(self, characteristic, status):
self.dispatcher.dispatch('on_gatt_release')
# uuid = characteristic.getUuid().toString()
self.dispatcher.dispatch('on_characteristic_read',
characteristic,
status)
@java_method('(Landroid/bluetooth/BluetoothGattCharacteristic;I)V')
def on_characteristic_write(self, characteristic, status):
self.dispatcher.dispatch('on_gatt_release')
# uuid = characteristic.getUuid().toString()
self.dispatcher.dispatch('on_characteristic_write',
characteristic,
status)
@java_method('(Landroid/bluetooth/BluetoothGattDescriptor;I)V')
def on_descriptor_read(self, descriptor, status):
self.dispatcher.dispatch('on_gatt_release')
# characteristic = descriptor.getCharacteristic()
# uuid = characteristic.getUuid().toString()
self.dispatcher.dispatch('on_descriptor_read', descriptor, status)
@java_method('(Landroid/bluetooth/BluetoothGattDescriptor;I)V')
def on_descriptor_write(self, descriptor, status):
self.dispatcher.dispatch('on_gatt_release')
# characteristic = descriptor.getCharacteristic()
# uuid = characteristic.getUuid().toString()
self.dispatcher.dispatch('on_descriptor_write', descriptor, status)
@java_method('(II)V')
def on_rssi_updated(self, rssi, status):
self.dispatcher.dispatch('on_gatt_release')
self.dispatcher.dispatch('on_rssi_updated', rssi, status)
@java_method('(II)V')
def on_mtu_changed(self, mtu, status):
self.dispatcher.dispatch('on_gatt_release')
self.dispatcher.dispatch('on_mtu_changed', mtu, status)
class PythonBluetoothAdvertiser(PythonJavaClass):
__javainterfaces__ = ['org.able.PythonBluetoothAdvertiser']
__javacontext__ = 'app'
def __init__(self, dispatcher):
super().__init__()
self.dispatcher = dispatcher
@java_method('(Landroid/bluetooth/le/AdvertisingSet;II)V')
def on_advertising_started(self, advertising_set, tx_power, status):
self.dispatcher.dispatch('on_advertising_set_changed', advertising_set)
self.dispatcher.dispatch('on_advertising_started', advertising_set, tx_power, status)
@java_method('(Landroid/bluetooth/le/AdvertisingSet;)V')
def on_advertising_stopped(self, advertising_set):
self.dispatcher.dispatch('on_advertising_set_changed', None)
self.dispatcher.dispatch('on_advertising_stopped', advertising_set)
@java_method('(Landroid/bluetooth/le/AdvertisingSet;BI)V')
def on_advertising_enabled(self, advertising_set, enable, status):
self.dispatcher.dispatch('on_advertising_enabled', advertising_set, enable, status)
@java_method('(Landroid/bluetooth/le/AdvertisingSet;I)V')
def on_advertising_data_set(self, advertising_set, status):
self.dispatcher.dispatch('on_advertising_data_set', advertising_set, status)
@java_method('(Landroid/bluetooth/le/AdvertisingSet;I)V')
def on_scan_response_data_set(self, advertising_set, status):
self.dispatcher.dispatch('on_scan_response_data_set', advertising_set, status)
@java_method('(Landroid/bluetooth/le/AdvertisingSet;II)V')
def on_advertising_parameters_updated(self, advertising_set, tx_power, status):
self.dispatcher.dispatch('on_advertising_parameters_updated', advertising_set, tx_power, status)

View file

@ -0,0 +1,371 @@
from typing import List, Optional
from kivy.event import EventDispatcher
from kivy.logger import Logger
from able import WriteType
from able.adapter import AdapterManager
from able.filters import Filter
from able.permissions import DEFAULT_RUNTIME_PERMISSIONS
from able.queue import BLEQueue, ble_task, ble_task_done
from able.scan_settings import ScanSettingsBuilder
from able.utils import force_convertible_to_java_array
class BLEError:
"""Raise Exception on attribute access"""
def __init__(self, msg):
self.msg = msg
def __getattr__(self, name):
raise Exception(self.msg)
class BluetoothDispatcherBase(EventDispatcher):
__events__ = (
"on_device",
"on_scan_started",
"on_scan_completed",
"on_services",
"on_connection_state_change",
"on_bluetooth_adapter_state_change",
"on_characteristic_changed",
"on_characteristic_read",
"on_characteristic_write",
"on_descriptor_read",
"on_descriptor_write",
"on_gatt_release",
"on_error",
"on_rssi_updated",
"on_mtu_changed",
)
queue_class = BLEQueue
def __init__(
self,
queue_timeout: float = 0.5,
enable_ble_code: int = 0xAB1E,
runtime_permissions: Optional[list[str]] = None, # DEFAULT_RUNTIME_PERMISSIONS
):
super(BluetoothDispatcherBase, self).__init__()
self.queue_timeout = queue_timeout
self.enable_ble_code = enable_ble_code
self.runtime_permissions = [
str(permission)
for permission in (
runtime_permissions
if runtime_permissions is not None
else DEFAULT_RUNTIME_PERMISSIONS
)
]
self._remote_device_address = None
self._set_ble_interface()
self._set_queue()
self._set_adapter_manager()
def _set_ble_interface(self):
self._ble = BLEError("BLE is not implemented for platform")
def _set_queue(self):
self.queue = self.queue_class(timeout=self.queue_timeout)
def _set_adapter_manager(self):
AdapterManager(
ble=self._ble,
enable_ble_code=self.enable_ble_code,
runtime_permissions=self.runtime_permissions,
).install(self)
def _check_runtime_permissions(self):
return True
def _request_runtime_permissions(self):
pass
@property
def adapter(self) -> Optional["android.bluetooth.BluetoothAdapter"]:
"""Local device Bluetooth adapter.
Could be `None` if adapter is not enabled or access to the adapter is not granted yet.
:type: `BluetoothAdapter <https://developer.android.com/reference/android/bluetooth/BluetoothAdapter>`_
`Java object <https://pyjnius.readthedocs.io/en/stable/api.html#jnius.JavaClass>`_
"""
@property
def gatt(self):
"""GATT profile of the connected device
:type: BluetoothGatt Java object
"""
return self._ble.getGatt()
@property
def bonded_devices(self):
"""List of Java `android.bluetooth.BluetoothDevice` objects of paired BLE devices.
:type: List[BluetoothDevice]
"""
return []
@property
def name(self):
"""Name of the Bluetooth adapter.
:setter: Set name of the Bluetooth adapter
:type: Optional[str]
"""
adapter = self.adapter
return adapter and adapter.getName()
@name.setter
def name(self, value):
self._set_name(value)
def _set_name(self, value):
pass
def set_queue_timeout(self, timeout):
"""Change the BLE operations queue timeout"""
self.queue_timeout = timeout
self.queue.set_timeout(timeout)
def start_scan(
self,
filters: Optional[List[Filter]] = None,
settings: Optional[ScanSettingsBuilder] = None,
):
"""Start a scan for devices.
The status of the scan start are reported with
:func:`scan_started <on_scan_started>` event.
:param filters: list of filters to restrict scan results.
Advertising record is considered matching the filters
if it matches any of the :class:`able.filters.Filter` in the list.
:param settings: scan settings
"""
pass
def stop_scan(self):
"""Stop the ongoing scan for devices."""
pass
def connect_by_device_address(self, address: str, autoconnect: bool = False):
"""Connect to GATT Server of the device with a given Bluetooth hardware address, without scanning.
:param address: Bluetooth hardware address string in "XX:XX:XX:XX:XX:XX" format
:param autoconnect: If True, automatically reconnects when available.
False = direct connect (default).
:raises:
ValueError: if `address` is not a valid Bluetooth address
"""
pass
def connect_gatt(self, device, autoconnect: bool = False):
"""Connect to GATT Server hosted by device
:param device: BluetoothDevice Java object
:param autoconnect: If True, automatically reconnects when available.
False = direct connect (default).
"""
self._ble.connectGatt(device, autoconnect)
def close_gatt(self):
"""Close current GATT client"""
self._ble.closeGatt()
def discover_services(self):
"""Discovers services offered by a remote device.
The status of the discovery reported with
:func:`services <on_services>` event.
:return: true, if the remote services discovery has been started
"""
return self.gatt.discoverServices()
def enable_notifications(self, characteristic, enable=True, indication=False):
"""Enable/disable notifications or indications for a given characteristic
:param characteristic: BluetoothGattCharacteristic Java object
:param enable: enable notifications if True, else disable notifications
:param indication: handle indications instead of notifications
:return: True, if the operation was initiated successfully
"""
return True
@ble_task
def write_descriptor(self, descriptor, value):
"""Set and write the value of a given descriptor to the associated
remote device
:param descriptor: BluetoothGattDescriptor Java object
:param value: value to write
"""
if not descriptor.setValue(force_convertible_to_java_array(value)):
Logger.error("Error on set descriptor value")
return
if not self.gatt.writeDescriptor(descriptor):
Logger.error("Error on descriptor write")
@ble_task
def write_characteristic(
self, characteristic, value, write_type: Optional[WriteType] = None
):
"""Write a given characteristic value to the associated remote device
:param characteristic: BluetoothGattCharacteristic Java object
:param value: value to write
:param write_type: specific write type to set for the characteristic
"""
self._ble.writeCharacteristic(
characteristic, force_convertible_to_java_array(value), int(write_type or 0)
)
@ble_task
def read_characteristic(self, characteristic):
"""Read a given characteristic from the associated remote device
:param characteristic: BluetoothGattCharacteristic Java object
"""
self._ble.readCharacteristic(characteristic)
@ble_task
def update_rssi(self):
"""Triggers an update for the RSSI from the associated remote device"""
self._ble.readRemoteRssi()
@ble_task
def request_mtu(self, mtu: int):
"""Request to change the ATT Maximum Transmission Unit value
:param value: new MTU size
"""
self.gatt.requestMtu(mtu)
def on_error(self, msg):
"""Error handler
:param msg: error message
"""
self._ble = BLEError(msg) # Exception for calls from another threads
raise Exception(msg)
@ble_task_done
def on_gatt_release(self):
"""`gatt_release` event handler.
Event is dispatched at every read/write completed operation
"""
pass
def on_scan_started(self, success):
"""`scan_started` event handler
:param success: true, if scan was started successfully
"""
pass
def on_scan_completed(self):
"""`scan_completed` event handler"""
pass
def on_device(self, device, rssi, advertisement):
"""`device` event handler.
Event is dispatched when device is found during a scan.
:param device: BluetoothDevice Java object
:param rssi: the RSSI value for the remote device
:param advertisement: :class:`Advertisement` data record
"""
pass
def on_connection_state_change(self, status, state):
"""`connection_state_change` event handler
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
:param state: STATE_CONNECTED or STATE_DISCONNECTED
"""
pass
def on_bluetooth_adapter_state_change(self, state):
"""`bluetooth_adapter_state_change` event handler
Allows the user to detect when bluetooth adapter is turned on/off.
:param state: STATE_OFF, STATE_TURNING_OFF, STATE_ON, STATE_TURNING_ON
"""
def on_services(self, services, status):
"""`services` event handler
:param services: :class:`Services` dict filled with discovered
characteristics
(BluetoothGattCharacteristic Java objects)
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
"""
pass
def on_characteristic_changed(self, characteristic):
"""`characteristic_changed` event handler
:param characteristic: BluetoothGattCharacteristic Java object
"""
pass
def on_characteristic_read(self, characteristic, status):
"""`characteristic_read` event handler
:param characteristic: BluetoothGattCharacteristic Java object
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
"""
pass
def on_characteristic_write(self, characteristic, status):
"""`characteristic_write` event handler
:param characteristic: BluetoothGattCharacteristic Java object
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
"""
pass
def on_descriptor_read(self, descriptor, status):
"""`descriptor_read` event handler
:param descriptor: BluetoothGattDescriptor Java object
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
"""
pass
def on_descriptor_write(self, descriptor, status):
"""`descriptor_write` event handler
:param descriptor: BluetoothGattDescriptor Java object
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
"""
pass
def on_rssi_updated(self, rssi, status):
"""`onReadRemoteRssi` event handler.
Event is dispatched at every RSSI update completed operation,
reporting a RSSI value for a remote device connection.
:param rssi: integer containing RSSI value in dBm
:param status: status of the operation,
`GATT_SUCCESS` if the operation succeeds
"""
pass
def on_mtu_changed(self, mtu, status):
"""`onMtuChanged` event handler
Event is dispatched when MTU for a remote device has changed,
reporting a new MTU size.
:param mtu: integer containing the new MTU size
:param status: status of the operation,
`GATT_SUCCESS` if the MTU has been changed successfully
"""
pass

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

@ -0,0 +1,237 @@
"""BLE scanning filters,
wrappers for Java class `android.bluetooth.le.ScanFilter.Builder`
https://developer.android.com/reference/android/bluetooth/le/ScanFilter.Builder
"""
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import List, Union
import uuid
from jnius import autoclass
ParcelUuid = autoclass('android.os.ParcelUuid')
BluetoothAdapter = autoclass('android.bluetooth.BluetoothAdapter')
ScanFilter = autoclass('android.bluetooth.le.ScanFilter')
ScanFilterBuilder = autoclass('android.bluetooth.le.ScanFilter$Builder')
@dataclass
class Filter:
"""Base class for BLE scanning fiters.
>>> # Filters of different kinds could be ANDed to set multiple conditions.
>>> # Both device name and address required:
>>> combined_filter = DeviceNameFilter("Example") & DeviceAddressFilter("01:02:03:AB:CD:EF")
>>> DeviceNameFilter("Example1") & DeviceNameFilter("Example2")
Traceback (most recent call last):
ValueError: cannot combine filters of the same type
"""
def __post_init__(self):
self.filters = [self]
def __and__(self, other):
if type(self) in (type(f) for f in other.filters):
raise ValueError('cannot combine filters of the same type')
self.filters.extend(other.filters)
return self
def build(self):
builder = ScanFilterBuilder()
for scan_filter in self.filters:
scan_filter.filter(builder)
return builder.build()
@abstractmethod
def filter(self, builder):
pass
class EmptyFilter(Filter):
"""Filter with no restrictions."""
def filter(self, builder):
pass
@dataclass
class DeviceAddressFilter(Filter):
"""Set filter on device address.
Uses Java method `ScanFilter.Builder.setDeviceAddress`.
:param address: Address in the format of "01:02:03:AB:CD:EF"
>>> DeviceAddressFilter("01:02:03:AB:CD:EF")
DeviceAddressFilter(address='01:02:03:AB:CD:EF')
"""
address: str
def __post_init__(self):
super().__post_init__()
if not BluetoothAdapter.checkBluetoothAddress(str(self.address)):
raise ValueError(f"{self.address} is not a valid Bluetooth address")
def filter(self, builder):
builder.setDeviceAddress(str(self.address))
@dataclass
class DeviceNameFilter(Filter):
"""Set filter on device name.
Uses Java method `ScanFilter.Builder.setDeviceName`.
:param name: Device name
"""
name: str
def filter(self, builder):
builder.setDeviceName(str(self.name))
@dataclass
class ManufacturerDataFilter(Filter):
"""Set filter on manufacture data.
Uses Java method `ScanFilter.Builder.setManufacturerData`.
:param id: Manufacturer ID
:param data: Manufacturer specific data
:param mask: bit mask for partial filtration of the `data`. For any bit in the mask,
set it to 1 if it needs to match the one in manufacturer data,
otherwise set it to 0 to ignore that bit.
>>> # Filter by just ID, ignoring the data:
>>> ManufacturerDataFilter(0x0AD0, [])
ManufacturerDataFilter(id=2768, data=[], mask=None)
>>> ManufacturerDataFilter(0x0AD0, [0x2, 0x15, 0x8d])
ManufacturerDataFilter(id=2768, data=[2, 21, 141], mask=None)
>>> # With mask set to ignore the second data byte:
>>> ManufacturerDataFilter(0x0AD0, [0x2, 0, 0x8d], [0xff, 0, 0xff])
ManufacturerDataFilter(id=2768, data=[2, 0, 141], mask=[255, 0, 255])
>>> ManufacturerDataFilter(0x0AD0, [0x2, 21, 0x8d], [0xff])
Traceback (most recent call last):
ValueError: mask is shorter than the data
"""
id: int
data: Union[list, tuple, bytes, bytearray]
mask: List[int] = field(default_factory=lambda: None)
def __post_init__(self):
super().__post_init__()
if self.mask and len(self.mask) < len(self.data):
raise ValueError('mask is shorter than the data')
def filter(self, builder):
if self.mask:
builder.setManufacturerData(self.id, self.data, self.mask)
else:
builder.setManufacturerData(self.id, self.data)
@dataclass
class ServiceDataFilter(Filter):
"""Set filter on service data.
Uses Java method `ScanFilter.Builder.setServiceData`.
:param uid: UUID of the service in the format of
"0000180f-0000-1000-8000-00805f9b34fb"
:param data: service data
:param mask: bit mask for partial filtration of the `data`. For any bit in the mask,
set it to 1 if it needs to match the one in service data,
otherwise set it to 0 to ignore that bit.
>>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [])
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[], mask=None)
>>> # With mask set to ignore the first data byte:
>>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0, 0x11], [0, 0xff])
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[0, 17], mask=[0, 255])
>>> ServiceDataFilter("0000180f", [])
Traceback (most recent call last):
ValueError: badly formed hexadecimal UUID string
>>> ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x12, 0x34], [0xff])
Traceback (most recent call last):
ValueError: mask is shorter than the data
"""
uid: str
data: Union[list, tuple, bytes, bytearray]
mask: List[int] = field(default_factory=lambda: None)
def __post_init__(self):
super().__post_init__()
# validate UUID value
uuid.UUID(self.uid)
if self.mask and len(self.mask) < len(self.data):
raise ValueError('mask is shorter than the data')
def filter(self, builder):
uid = ParcelUuid.fromString(self.uid)
if self.mask:
builder.setServiceData(uid, self.data, self.mask)
else:
builder.setServiceData(uid, self.data)
@dataclass
class ServiceSolicitationFilter(Filter):
"""Set filter on service solicitation uuid.
Uses Java method `ScanFilter.Builder.setServiceSolicitation`.
:param uid: UUID of the service in the format of
"0000180f-0000-1000-8000-00805f9b34fb"
"""
uid: str
def filter(self, builder):
uid = ParcelUuid.fromString(self.uid)
builder.setServiceSolicitation(uid)
@dataclass
class ServiceUUIDFilter(Filter):
"""Set filter on service uuid.
Uses Java method `ScanFilter.Builder.setServiceUuid`.
:param uid: UUID of the service in the format of
"0000180f-0000-1000-8000-00805f9b34fb"
:mask: bit mask for partial filtration of the UUID, in the format of
"ffffffff-0000-0000-0000-ffffffffffff". Set any bit in the mask
to 1 to indicate a match is needed for the bit in `uid`,
and 0 to ignore that bit.
>>> ServiceUUIDFilter('16fe0d00-c111-11e3-b8c8-0002a5d5c51b')
ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51b', mask=None)
>>> ServiceUUIDFilter(
... '16fe0d00-c111-11e3-b8c8-0002a5d5c51b',
... 'ffffffff-0000-0000-0000-000000000000'
... ) #doctest: +ELLIPSIS
ServiceUUIDFilter(uid='16fe0d00-...', mask='ffffffff-...')
>>> ServiceUUIDFilter('123')
Traceback (most recent call last):
ValueError: badly formed hexadecimal UUID string
"""
uid: str
mask: str = None
def __post_init__(self):
super().__post_init__()
# validate UUID values
uuid.UUID(self.uid)
if self.mask:
uuid.UUID(self.mask)
def filter(self, builder):
uid = ParcelUuid.fromString(self.uid)
if self.mask:
mask = ParcelUuid.fromString(self.mask)
builder.setServiceUuid(uid, mask)
else:
builder.setServiceUuid(uid)

View file

@ -0,0 +1,53 @@
"""Before executing, all :class:`BluetoothDispatcher <able.BluetoothDispatcher>` methods that requires Bluetooth adapter
(`start_scan`, `connect_by_device_address`, `enable_notifications`, `adapter` property ...),
are asking the user to:
#. grant runtime permissions,
#. turn on Bluetooth adapter.
The list of requested runtime permissions varies depending on the level of the target Android API level:
* target API level <=30: ACCESS_FINE_LOCATION - to obtain BLE scan results
* target API level >= 31:
* BLUETOOTH_CONNECT - to enable adapter and to connect to devices
* BLUETOOTH_SCAN - to start the scan
* ACCESS_FINE_LOCATION - to detect beacons during the scan
* BLUETOOTH_ADVERTISE - to be able to advertise to nearby Bluetooth devices
Requested permissions list can be changed with the `BluetoothDispatcher.runtime_permissions` parameter.
"""
from jnius import autoclass
SDK_INT = int(autoclass("android.os.Build$VERSION").SDK_INT)
class Permission:
"""
String constants values for BLE-related permissions.
https://developer.android.com/reference/android/Manifest.permission
"""
ACCESS_BACKGROUND_LOCATION = "android.permission.ACCESS_BACKGROUND_LOCATION"
ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION"
BLUETOOTH_ADVERTISE = "android.permission.BLUETOOTH_ADVERTISE"
BLUETOOTH_CONNECT = "android.permission.BLUETOOTH_CONNECT"
BLUETOOTH_SCAN = "android.permission.BLUETOOTH_SCAN"
if SDK_INT >= 31:
# API level 31 (Android 12) introduces new permissions
DEFAULT_RUNTIME_PERMISSIONS = [
Permission.BLUETOOTH_ADVERTISE,
Permission.BLUETOOTH_CONNECT,
Permission.BLUETOOTH_SCAN,
# ACCESS_FINE_LOCATION is not mandatory for scan,
# but required to discover beacons
Permission.ACCESS_FINE_LOCATION,
]
else:
# For API levels 29-30,
# ACCESS_FINE_LOCATION permission is needed to obtain BLE scan results
DEFAULT_RUNTIME_PERMISSIONS = [
Permission.ACCESS_FINE_LOCATION,
]

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

@ -0,0 +1,90 @@
import threading
from functools import wraps, partial
try:
from queue import Empty, Queue
except ImportError:
from Queue import Empty, Queue
from kivy.clock import Clock
from kivy.logger import Logger
def ble_task(method):
"""
Enque method
"""
@wraps(method)
def wrapper(obj, *args, **kwargs):
task = partial(method, obj, *args, **kwargs)
obj.queue.enque(task)
return wrapper
def ble_task_done(method):
@wraps(method)
def wrapper(obj, *args, **kwargs):
obj.queue.done(*args, **kwargs)
method(obj, *args, **kwargs)
return wrapper
def with_lock(method):
@wraps(method)
def wrapped(obj, *args, **kwargs):
locked = obj.lock.acquire(False)
if locked:
try:
return method(obj, *args, **kwargs)
finally:
obj.lock.release()
return wrapped
class BLEQueue(object):
def __init__(self, timeout=0):
self.lock = threading.Lock()
self.ready = True
self.queue = Queue()
self.set_timeout(timeout)
def set_timeout(self, timeout):
Logger.debug("set queue timeout to {}".format(timeout))
self.timeout = timeout
self.timeout_event = Clock.schedule_once(
self.on_timeout, self.timeout or 0)
self.timeout_event.cancel()
def enque(self, task):
queue = self.queue
if self.timeout == 0:
self.execute_task(task)
else:
queue.put_nowait(task)
self.execute_next()
@with_lock
def execute_next(self, ready=False):
if ready:
self.ready = True
elif not self.ready:
return
try:
task = self.queue.get_nowait()
except Empty:
return
self.ready = False
if task is not None:
self.execute_task(task)
def done(self, *args, **kwargs):
self.timeout_event.cancel()
self.ready = True
self.execute_next()
def on_timeout(self, *args, **kwargs):
self.done()
def execute_task(self, task):
if self.timeout and self.timeout_event:
self.timeout_event()
task()

View file

@ -0,0 +1,20 @@
"""BLE scanning settings.
"""
from jnius import autoclass
from kivy.utils import platform
if platform != 'android':
class ScanSettings:
"""PyJNIus wrapper for Java class `android.bluetooth.le.ScanSettings`.
https://developer.android.com/reference/android/bluetooth/le/ScanSettings
"""
class ScanSettingsBuilder:
"""PyJNIus wrapper for Java class `android.bluetooth.le.ScanSettings.Builder`.
https://developer.android.com/reference/android/bluetooth/le/ScanSettings.Builder
"""
else:
ScanSettings = autoclass('android.bluetooth.le.ScanSettings')
ScanSettingsBuilder = autoclass('android.bluetooth.le.ScanSettings$Builder')

View file

@ -0,0 +1,283 @@
package org.able;
import android.app.Activity;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanSettings;
import android.os.Handler;
import android.util.Log;
import java.util.List;
import org.kivy.android.PythonActivity;
import org.kivy.android.PythonService;
import org.able.PythonBluetooth;
public class BLE {
private String TAG = "BLE-python";
private PythonBluetooth mPython;
private Context mContext;
private BluetoothAdapter mBluetoothAdapter;
private BluetoothLeScanner mBluetoothLeScanner;
private BluetoothGatt mBluetoothGatt;
private List<BluetoothGattService> mBluetoothGattServices;
private boolean mScanning;
private boolean mIsServiceContext = false;
public void showError(final String msg) {
Log.e(TAG, msg);
if (!mIsServiceContext) { PythonActivity.mActivity.toastError(TAG + " error. " + msg); }
mPython.on_error(msg);
}
public BLE(PythonBluetooth python) {
mPython = python;
mContext = (Context) PythonActivity.mActivity;
mBluetoothGatt = null;
if (mContext == null) {
Log.d(TAG, "Service context detected");
mIsServiceContext = true;
mContext = (Context) PythonService.mService;
}
if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
showError("Device does not support Bluetooth Low Energy.");
return;
}
final BluetoothManager bluetoothManager =
(BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE);
mBluetoothAdapter = bluetoothManager.getAdapter();
mContext.registerReceiver(mReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
}
public BluetoothAdapter getAdapter(int EnableBtCode) {
if (mBluetoothAdapter == null) {
showError("Device do not support Bluetooth Low Energy.");
return null;
}
if (!mBluetoothAdapter.isEnabled()) {
if (mIsServiceContext) {
showError("BLE adapter is not enabled");
} else {
Log.d(TAG, "BLE adapter is not enabled");
Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
PythonActivity.mActivity.startActivityForResult(enableBtIntent, EnableBtCode);
}
return null;
}
return mBluetoothAdapter;
}
public BluetoothGatt getGatt() {
return mBluetoothGatt;
}
public void startScan(int EnableBtCode,
List<ScanFilter> filters,
ScanSettings settings) {
Log.d(TAG, "startScan");
BluetoothAdapter adapter = getAdapter(EnableBtCode);
if (adapter != null) {
Log.d(TAG, "BLE adapter is ready for scan");
if (mBluetoothLeScanner == null) {
mBluetoothLeScanner = adapter.getBluetoothLeScanner();
}
if (mBluetoothLeScanner != null) {
mScanning = false;
mBluetoothLeScanner.startScan(filters, settings, mScanCallback);
} else {
showError("Could not get BLE Scanner object.");
mPython.on_scan_started(false);
}
}
}
public void stopScan() {
if (mBluetoothLeScanner != null) {
Log.d(TAG, "stopScan");
mBluetoothLeScanner.stopScan(mScanCallback);
if (mScanning) {
mScanning = false;
mPython.on_scan_completed();
}
}
}
private final ScanCallback mScanCallback =
new ScanCallback() {
@Override
public void onScanResult(final int callbackType, final ScanResult result) {
if (!mScanning) {
mScanning = true;
Log.d(TAG, "BLE scan started successfully");
mPython.on_scan_started(true);
}
if (mIsServiceContext) {
mPython.on_scan_result(result);
return;
}
PythonActivity.mActivity.runOnUiThread(new Runnable() {
@Override
public void run() {
mPython.on_scan_result(result);
}
});
}
@Override
public void onBatchScanResults(List<ScanResult> results) {
Log.d(TAG, "onBatchScanResults");
}
@Override
public void onScanFailed(int errorCode) {
Log.e(TAG, "BLE Scan failed, error code:" + errorCode);
mPython.on_scan_started(false);
}
};
public void connectGatt(BluetoothDevice device) {
connectGatt(device, false);
}
public void connectGatt(BluetoothDevice device, boolean autoConnect) {
Log.d(TAG, "connectGatt");
if (mBluetoothGatt == null) {
mBluetoothGatt = device.connectGatt(mContext, autoConnect, mGattCallback, BluetoothDevice.TRANSPORT_LE);
} else {
Log.d(TAG, "BluetoothGatt object exists, use either closeGatt() to close Gatt or BluetoothGatt.connect() to re-connect");
}
}
public void closeGatt() {
Log.d(TAG, "closeGatt");
if (mBluetoothGatt != null) {
mBluetoothGatt.close();
mBluetoothGatt = null;
}
}
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) {
Log.d(TAG, "onReceive - BluetoothAdapter state changed");
int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1);
mPython.on_bluetooth_adapter_state_change(state);
}
}
};
private final BluetoothGattCallback mGattCallback =
new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.d(TAG, "Connected to GATT server, status:" + status);
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "Disconnected from GATT server, status:" + status);
}
if (mBluetoothGatt == null) {
mBluetoothGatt = gatt;
}
mPython.on_connection_state_change(status, newState);
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d(TAG, "onServicesDiscovered - success");
mBluetoothGattServices = mBluetoothGatt.getServices();
} else {
showError("onServicesDiscovered status:" + status);
mBluetoothGattServices = null;
}
mPython.on_services(status, mBluetoothGattServices);
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
mPython.on_characteristic_changed(characteristic);
}
@Override
public void onCharacteristicRead(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic,
int status) {
mPython.on_characteristic_read(characteristic, status);
}
@Override
public void onCharacteristicWrite(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic,
int status) {
mPython.on_characteristic_write(characteristic, status);
}
@Override
public void onDescriptorRead(BluetoothGatt gatt,
BluetoothGattDescriptor descriptor,
int status) {
mPython.on_descriptor_read(descriptor, status);
}
@Override
public void onDescriptorWrite(BluetoothGatt gatt,
BluetoothGattDescriptor descriptor,
int status) {
mPython.on_descriptor_write(descriptor, status);
}
@Override
public void onReadRemoteRssi(BluetoothGatt gatt,
int rssi, int status) {
mPython.on_rssi_updated(rssi, status);
}
@Override
public void onMtuChanged(BluetoothGatt gatt,
int mtu, int status) {
Log.d(TAG, String.format("onMtuChanged mtu=%d status=%d", mtu, status));
mPython.on_mtu_changed(mtu, status);
}
};
public boolean writeCharacteristic(BluetoothGattCharacteristic characteristic, byte[] data, int writeType) {
if (characteristic.setValue(data)) {
if (writeType != 0) {
characteristic.setWriteType(writeType);
}
return mBluetoothGatt.writeCharacteristic(characteristic);
}
return false;
}
public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {
return mBluetoothGatt.readCharacteristic(characteristic);
}
public boolean readRemoteRssi() {
return mBluetoothGatt.readRemoteRssi();
}
}

View file

@ -0,0 +1,61 @@
package org.able;
import android.bluetooth.le.AdvertisingSet;
import android.bluetooth.le.AdvertisingSetCallback;
import org.able.PythonBluetoothAdvertiser;
import android.util.Log;
public class BLEAdvertiser {
private String TAG = "BLE-python";
private PythonBluetoothAdvertiser mPython;
public AdvertisingSetCallback mCallbackSet;
public BLEAdvertiser(PythonBluetoothAdvertiser python) {
mPython = python;
}
public AdvertisingSetCallback createCallback() {
mCallbackSet = new AdvertisingSetCallback() {
@Override
public void onAdvertisingSetStarted(AdvertisingSet advertisingSet, int txPower, int status) {
Log.d(TAG, "onAdvertisingSetStarted, status:" + status);
mPython.on_advertising_started(advertisingSet, txPower, status);
}
@Override
public void onAdvertisingSetStopped(AdvertisingSet advertisingSet) {
Log.d(TAG, "onAdvertisingSetStopped");
mCallbackSet = null;
mPython.on_advertising_stopped(advertisingSet);
}
@Override
public void onAdvertisingEnabled(AdvertisingSet advertisingSet, boolean enable, int status) {
Log.d(TAG, "onAdvertisingEnabled, enable:" + enable + "status:" + status);
mPython.on_advertising_enabled(advertisingSet, enable, status);
}
@Override
public void onAdvertisingDataSet(AdvertisingSet advertisingSet, int status) {
Log.d(TAG, "onAdvertisingDataSet, status:" + status);
mPython.on_advertising_data_set(advertisingSet, status);
}
@Override
public void onScanResponseDataSet(AdvertisingSet advertisingSet, int status) {
Log.d(TAG, "onScanResponseDataSet, status:" + status);
mPython.on_scan_response_data_set(advertisingSet, status);
}
@Override
public void onAdvertisingParametersUpdated(AdvertisingSet advertisingSet, int txPower, int status) {
Log.d(TAG, "onAdvertisingParametersUpdated, status:" + status);
mPython.on_advertising_parameters_updated(advertisingSet, txPower, status);
}
};
return mCallbackSet;
}
}

View file

@ -0,0 +1,25 @@
package org.able;
import java.util.List;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.le.ScanResult;
interface PythonBluetooth
{
public void on_error(String msg);
public void on_scan_started(boolean success);
public void on_scan_result(ScanResult result);
public void on_scan_completed();
public void on_services(int status, List<BluetoothGattService> services);
public void on_characteristic_changed(BluetoothGattCharacteristic characteristic);
public void on_characteristic_read(BluetoothGattCharacteristic characteristic, int status);
public void on_characteristic_write(BluetoothGattCharacteristic characteristic, int status);
public void on_descriptor_read(BluetoothGattDescriptor descriptor, int status);
public void on_descriptor_write(BluetoothGattDescriptor descriptor, int status);
public void on_connection_state_change(int status, int state);
public void on_bluetooth_adapter_state_change(int state);
public void on_rssi_updated(int rssi, int status);
public void on_mtu_changed (int mtu, int status);
}

View file

@ -0,0 +1,13 @@
package org.able;
import android.bluetooth.le.AdvertisingSet;
interface PythonBluetoothAdvertiser
{
public void on_advertising_started(AdvertisingSet advertisingSet, int txPower, int status);
public void on_advertising_stopped(AdvertisingSet advertisingSet);
public void on_advertising_enabled(AdvertisingSet advertisingSet, boolean enable, int status);
public void on_advertising_data_set(AdvertisingSet advertisingSet, int status);
public void on_scan_response_data_set(AdvertisingSet advertisingSet, int status);
public void on_advertising_parameters_updated(AdvertisingSet advertisingSet, int txPower, int status);
}

View file

@ -0,0 +1,81 @@
import re
from collections import namedtuple
class Advertisement(object):
"""Advertisement data record parser
>>> ad = Advertisement([2, 1, 0x6, 6, 255, 82, 83, 95, 82, 48])
>>> for data in ad:
... data
AD(ad_type=1, data=bytearray(b'\\x06'))
AD(ad_type=255, data=bytearray(b'RS_R0'))
>>> list(ad)[0].ad_type == Advertisement.ad_types.flags
True
"""
AD = namedtuple("AD", ['ad_type', 'data'])
class ad_types:
"""
Assigned numbers for some of `advertisement data types
<https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/>`_.
flags : "Flags" (0x01)
complete_local_name : "Complete Local Name" (0x09)
service_data : "Service Data" (0x16)
manufacturer_specific_data : "Manufacturer Specific Data" (0xff)
"""
flags = 0x01
complete_local_name = 0x09
service_data = 0x16
manufacturer_specific_data = 0xff
def __init__(self, data):
self.data = data
def __iter__(self):
return Advertisement.parse(self.data)
@classmethod
def parse(cls, data):
pos = 0
while pos < len(data):
length = data[pos]
if length < 2:
return
try:
ad_type = data[pos + 1]
except IndexError:
return
next_pos = pos + length + 1
if ad_type:
segment = slice(pos + 2, next_pos)
yield Advertisement.AD(ad_type, bytearray(data[segment]))
pos = next_pos
class Services(dict):
"""Services dict
>>> services = Services({'service0': {'c1-aa': 0, 'aa-c2-aa': 1},
... 'service1': {'bb-c3-bb': 2}})
>>> services.search('c3')
2
>>> services.search('c4')
"""
def search(self, pattern, flags=re.IGNORECASE):
"""Search for characteristic by pattern
:param pattern: regexp pattern
:param flags: regexp flags, re.IGNORECASE by default
"""
for characteristics in self.values():
for uuid, characteristic in characteristics.items():
if re.search(pattern, uuid, flags):
return characteristic

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

@ -0,0 +1,42 @@
from typing import Any, Union
def force_convertible_to_java_array(
value: Any
) -> Union[list, tuple, bytes, bytearray]:
"""Construct a value that is convertible to a Java array.
>>> force_convertible_to_java_array([3, 1, 4])
[3, 1, 4]
>>> force_convertible_to_java_array(['314'])
['314']
>>> force_convertible_to_java_array('314')
b'314'
>>> force_convertible_to_java_array(314)
[314]
>>> force_convertible_to_java_array(0)
[0]
>>> force_convertible_to_java_array('')
[]
>>> force_convertible_to_java_array(None)
[]
>>> force_convertible_to_java_array({})
[]
"""
if isinstance(value, (list, tuple, bytes, bytearray)):
return value
try:
return value.encode() or []
except AttributeError:
pass
try:
return list(value)
except TypeError:
pass
if value is None:
return []
return [value]

View file

@ -0,0 +1,5 @@
"""Package version.
This file is filled with actual value during the PyPI package build.
Development version is always "0.0.0".
"""
__version__ = '0.0.0'

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

@ -0,0 +1,177 @@
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " xml to make Docutils-native XML files"
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
clean:
rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/ABLE.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/ABLE.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/ABLE"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/ABLE"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
latexpdfja:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through platex and dvipdfmx..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
xml:
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
@echo
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."

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

@ -0,0 +1,154 @@
API
---
.. automodule:: able
Client
^^^^^^
BluetoothDispatcher
"""""""""""""""""""
.. autoclass:: BluetoothDispatcher
:members: adapter,
gatt,
bonded_devices,
name,
set_queue_timeout,
start_scan,
stop_scan,
connect_by_device_address,
connect_gatt,
close_gatt,
discover_services,
enable_notifications,
write_descriptor,
write_characteristic,
read_characteristic,
update_rssi,
request_mtu,
on_error,
on_gatt_release,
on_scan_started,
on_scan_completed,
on_device,
on_bluetooth_adapter_state_changeable,
on_connection_state_change,
on_services,
on_characteristic_changed,
on_characteristic_read,
on_characteristic_write,
on_descriptor_read,
on_descriptor_write,
on_rssi_updated,
on_mtu_changed,
Decorators
""""""""""
.. autofunction:: require_bluetooth_enabled
Advertisement
"""""""""""""
.. autoclass:: Advertisement
.. autoclass:: able::Advertisement.ad_types
Services
""""""""
.. autoclass:: Services
:members:
Constants
"""""""""
.. autodata:: GATT_SUCCESS
.. autodata:: STATE_CONNECTED
.. autodata:: STATE_DISCONNECTED
.. autoclass:: AdapterState
:members:
:member-order: bysource
.. autoclass:: WriteType
:members:
Permissions
^^^^^^^^^^^
.. automodule:: able.permissions
.. automodule:: able
.. autoclass:: Permission
:members:
:undoc-members:
:member-order: bysource
Scan settings
^^^^^^^^^^^^^
.. automodule:: able.scan_settings
.. autoclass:: ScanSettingsBuilder
.. autoclass:: ScanSettings
>>> settings = ScanSettingsBuilder() \
... .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) \
... .setCallbackType(
... ScanSettings.CALLBACK_TYPE_FIRST_MATCH |
... ScanSettings.CALLBACK_TYPE_MATCH_LOST
... )
Scan filters
^^^^^^^^^^^^
.. automodule:: able.filters
:members:
:member-order: bysource
:show-inheritance:
Advertising
^^^^^^^^^^^
.. automodule:: able.advertising
Advertiser
""""""""""
.. autoclass:: Advertiser
:members:
:member-order: bysource
Payload
"""""""
.. autoclass:: AdvertiseData
.. autoclass:: DeviceName
:show-inheritance:
.. autoclass:: TXPowerLevel
:show-inheritance:
.. autoclass:: ServiceUUID
:show-inheritance:
.. autoclass:: ServiceData
:show-inheritance:
.. autoclass:: ManufacturerData
:show-inheritance:
Constants
"""""""""
.. autoclass:: Interval
:members:
:member-order: bysource
.. autoclass:: TXPower
:members:
:member-order: bysource
.. autoclass:: Status
:members:
:undoc-members:
:member-order: bysource

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

@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
#
# ABLE documentation build configuration file, created by
# sphinx-quickstart on Sun Apr 16 23:19:55 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys
import os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
# sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath('..'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'ABLE'
copyright = u'2017, b3b'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'ABLEdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'ABLE.tex', u'ABLE Documentation',
u'b3b', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'able', u'ABLE Documentation',
[u'b3b'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'ABLE', u'ABLE Documentation',
u'b3b', 'ABLE', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False
# http://stackoverflow.com/questions/28366818/preserve-default-arguments-of-wrapped-decorated-python-function-in-sphinx-document
# Monkey-patch functools.wraps
import functools
def no_op_wraps(func):
"""Replaces functools.wraps in order to undo wrapping.
Can be used to preserve the decorated function's signature
in the documentation generated by Sphinx.
"""
def wrapper(decorator):
return func
return wrapper
functools.wraps = no_op_wraps
# http://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules
# I get import errors on libraries that depend on C modules
from mock import MagicMock
class Mock(MagicMock):
@classmethod
def __getattr__(cls, name):
return MagicMock()
MOCK_MODULES = ['kivy', 'kivy.utils', 'kivy.clock', 'kivy.logger',
'kivy.event']
sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
sys.modules['kivy.event'].EventDispatcher = object

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

@ -0,0 +1,192 @@
Usage Examples
==============
Alert
-----
.. literalinclude:: ./examples/alert.py
:language: python
Full example code: `alert <https://github.com/b3b/able/blob/master/examples/alert/>`_
Change MTU
----------
.. literalinclude:: ./examples/mtu.py
:language: python
Scan settings
-------------
.. code-block:: python
from able import BluetoothDispatcher
from able.scan_settings import ScanSettingsBuilder, ScanSettings
# Use faster detection (more power usage) mode
settings = ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
BluetoothDispatcher().start_scan(settings=settings)
Scan filters
------------
.. code-block:: python
from able import BluetoothDispatcher
from able.filters import (
DeviceAddressFilter,
DeviceNameFilter,
ManufacturerDataFilter,
ServiceDataFilter,
ServiceUUIDFilter
)
ble = BluetoothDispatcher()
# Start scanning with the condition that device has one of names: "Device1" or "Device2"
ble.start_scan(filters=[DeviceNameFilter("Device1"), DeviceNameFilter("Device2")])
ble.stop_scan()
# Start scanning with the condition that
# device advertises "180f" service and one of names: "Device1" or "Device2"
ble.start_scan(filters=[
ServiceUUIDFilter('0000180f-0000-1000-8000-00805f9b34fb') & DeviceNameFilter("Device1"),
ServiceUUIDFilter('0000180f-0000-1000-8000-00805f9b34fb') & DeviceNameFilter("Device2")
])
Adapter state
-------------
.. literalinclude:: ./examples/adapter_state_change.py
:language: python
Advertising
-----------
Advertise with data and additional (scannable) data
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
from able import BluetoothDispatcher
from able.advertising import (
Advertiser,
AdvertiseData,
ManufacturerData,
Interval,
ServiceUUID,
ServiceData,
TXPower,
)
advertiser = Advertiser(
ble=BluetoothDispatcher(),
data=AdvertiseData(ServiceUUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")),
scan_data=AdvertiseData(ManufacturerData(id=0xAABB, data=b"some data")),
interval=Interval.MEDIUM,
tx_power=TXPower.MEDIUM,
)
advertiser.start()
Set and advertise device name
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
from able import BluetoothDispatcher
from able.advertising import Advertiser, AdvertiseData, DeviceName
ble = BluetoothDispatcher()
ble.name = "New test device name"
# There must be a wait and check, it takes time for new name to take effect
print(f"New device name is set: {ble.name}")
Advertiser(
ble=ble,
data=AdvertiseData(DeviceName())
)
Battery service data
^^^^^^^^^^^^^^^^^^^^
.. literalinclude:: ./examples/advertising_battery.py
:language: python
Use iBeacon advertising format
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
import uuid
from able import BluetoothDispatcher
from able.advertising import Advertiser, AdvertiseData, ManufacturerData
data = AdvertiseData(
ManufacturerData(
0x4C, # Apple Manufacturer ID
bytes([
0x2, # SubType: Custom Manufacturer Data
0x15 # Subtype lenth
]) +
uuid.uuid4().bytes + # UUID of beacon
bytes([
0, 15, # Major value
0, 1, # Minor value
10 # RSSI, dBm at 1m
]))
)
Advertiser(BluetoothDispatcher(), data).start()
Android Services
----------------
BLE devices scanning service
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**main.py**
.. literalinclude:: ./examples/service_scan_main.py
:language: python
**service.py**
.. literalinclude:: ./examples/service_scan_service.py
:language: python
Full example code: `service_scan <https://github.com/b3b/able/blob/master/examples/service_scan/>`_
Advertising service
^^^^^^^^^^^^^^^^^^^
**main.py**
.. literalinclude:: ./examples/service_advertise_main.py
:language: python
**service.py**
.. literalinclude:: ./examples/service_advertise_service.py
:language: python
Full example code: `service_advertise <https://github.com/b3b/able/blob/master/examples/service_advertise/>`_
Connect to multiple devices
---------------------------
.. literalinclude:: ./examples/multi_devices/main.py
:language: python
Full example code: `multi_devices <https://github.com/b3b/able/blob/master/examples/multi_devices/>`_

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

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

View file

@ -0,0 +1,27 @@
"""Detect and log Bluetooth adapter state change."""
from typing import Optional
from kivy.logger import Logger
from kivy.uix.widget import Widget
from able import AdapterState, BluetoothDispatcher
class Dispatcher(BluetoothDispatcher):
def on_bluetooth_adapter_state_change(self, state: int):
Logger.info(
f"Bluetoth adapter state changed to {state} ('{AdapterState(state).name}')."
)
if state == AdapterState.OFF:
Logger.info("Adapter state changed to OFF.")
class StateChangeApp(App):
def build(self):
Dispatcher()
return Widget()
if __name__ == "__main__":
StateChangeApp.run()

View file

@ -0,0 +1,71 @@
"""Advertise battery level, that degrades every second."""
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.label import Label
from able import BluetoothDispatcher
from able import advertising
# Standard fully-qualified UUID for the Battery Service
BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
class BatteryAdvertiser(advertising.Advertiser):
def on_advertising_started(self, advertising_set, tx_power, status):
if status == advertising.Status.SUCCESS:
print("Advertising is started successfully")
else:
print(f"Advertising start error status: {status}")
def on_advertising_stopped(self, advertising_set):
print("Advertising stopped")
class BatteryLabel(Label):
"""Widget to control advertiser and show current battery level."""
def __init__(self):
self._level = 0
super().__init__(text="Waiting for advertising to start...")
self.advertiser = BatteryAdvertiser(
ble=BluetoothDispatcher(),
data=self.construct_data(level=100),
interval=advertising.Interval.MIN
)
self.advertiser.bind(on_advertising_started=self.on_started) # bind to start of advertising
self.advertiser.start()
def on_started(self, advertiser, advertising_set, tx_power, status):
if status == advertising.Status.SUCCESS:
# Advertising is started - update battery level every second
self.clock = Clock.schedule_interval(self.update_level, 1)
def update_level(self, dt):
level = self._level = (self._level - 1) % 101
self.text = str(level)
if level > 0:
# Set new advertising data
self.advertiser.data = self.construct_data(level)
else:
self.clock.cancel()
# Stop advertising
self.advertiser.stop()
def construct_data(self, level):
return advertising.AdvertiseData(
advertising.DeviceName(),
advertising.TXPowerLevel(),
advertising.ServiceData(BATTERY_SERVICE_UUID, [level])
)
class BatteryApp(App):
def build(self):
return BatteryLabel()
if __name__ == "__main__":
BatteryApp().run()

View file

@ -0,0 +1,27 @@
[app]
title = Alert Mi
version = 1.1
package.name = alert_mi
package.domain = org.kivy
source.dir = .
source.include_exts = py,png,jpg,kv,atlas
requirements = python3,kivy,android,able_recipe
android.accept_sdk_license = True
android.permissions =
BLUETOOTH,
BLUETOOTH_ADMIN,
BLUETOOTH_SCAN,
BLUETOOTH_CONNECT,
BLUETOOTH_ADVERTISE,
ACCESS_FINE_LOCATION
# android.api = 31
# android.minapi = 31
[buildozer]
warn_on_root = 1
# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

View file

@ -0,0 +1,21 @@
<ErrorMessage>:
BoxLayout:
orientation: 'vertical'
padding: 10
spacing: 20
Label:
size_hint_y: None
font_size: '18sp'
height: '24sp'
text: 'Application has crashed, details: '
ScrollView:
size_hint: 1, 1
TextInput:
text: root.message
size_hint: 1, None
height: self.minimum_height
Button:
size_hint_y: None
height: '40sp'
text: 'OK, terminate'
on_press: root.dismiss()

View file

@ -0,0 +1,39 @@
import os
import traceback
from kivy.base import (
ExceptionHandler,
ExceptionManager,
stopTouchApp,
)
from kivy.properties import StringProperty
from kivy.uix.popup import Popup
from kivy.lang import Builder
from kivy.logger import Logger
Builder.load_file(os.path.join(os.path.dirname(__file__), 'error_message.kv'))
class ErrorMessageOnException(ExceptionHandler):
def handle_exception(self, exception):
Logger.exception('Unhandled Exception catched')
message = ErrorMessage(message=traceback.format_exc())
def raise_exception(*ar2gs, **kwargs):
stopTouchApp()
raise Exception("Exit due to errors")
message.bind(on_dismiss=raise_exception)
message.open()
return ExceptionManager.PASS
class ErrorMessage(Popup):
title = StringProperty('Bang!')
message = StringProperty('')
def install_exception_handler():
ExceptionManager.add_handler(ErrorMessageOnException())

View file

@ -0,0 +1,64 @@
"""Turn the alert on Mi Band device
"""
from kivy.app import App
from kivy.uix.button import Button
from able import BluetoothDispatcher, GATT_SUCCESS
from error_message import install_exception_handler
class BLE(BluetoothDispatcher):
device = alert_characteristic = None
def start_alert(self, *args, **kwargs):
if self.alert_characteristic: # alert service is already discovered
self.alert(self.alert_characteristic)
elif self.device: # device is already founded during the scan
self.connect_gatt(self.device) # reconnect
else:
self.stop_scan() # stop previous scan
self.start_scan() # start a scan for devices
def on_device(self, device, rssi, advertisement):
# some device is found during the scan
name = device.getName()
if name and name.startswith('MI'): # is a Mi Band device
self.device = device
self.stop_scan()
def on_scan_completed(self):
if self.device:
self.connect_gatt(self.device) # connect to device
def on_connection_state_change(self, status, state):
if status == GATT_SUCCESS and state: # connection established
self.discover_services() # discover what services a device offer
else: # disconnection or error
self.alert_characteristic = None
self.close_gatt() # close current connection
def on_services(self, status, services):
# 0x2a06 is a standard code for "Alert Level" characteristic
# https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.alert_level.xml
self.alert_characteristic = services.search('2a06')
self.alert(self.alert_characteristic)
def alert(self, characteristic):
self.write_characteristic(characteristic, [2]) # 2 is for "High Alert"
class AlertApp(App):
def build(self):
self.ble = None
return Button(text='Press to Alert Mi', on_press=self.start_alert)
def start_alert(self, *args, **kwargs):
if not self.ble:
self.ble = BLE()
self.ble.start_alert()
if __name__ == '__main__':
install_exception_handler()
AlertApp().run()

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

@ -0,0 +1,52 @@
"""Request MTU change, and write 100 bytes to a characteristic."""
from kivy.app import App
from kivy.clock import Clock
from kivy.logger import Logger
from kivy.uix.widget import Widget
from able import BluetoothDispatcher, GATT_SUCCESS
class BLESender(BluetoothDispatcher):
def __init__(self):
super().__init__()
self.characteristic_to_write = None
Clock.schedule_once(self.connect, 0)
def connect(self, _):
self.connect_by_device_address("FF:FF:FF:FF:FF:FF")
def on_connection_state_change(self, status, state):
if status == GATT_SUCCESS and state:
self.discover_services()
def on_services(self, status, services):
if status == GATT_SUCCESS:
self.characteristic_to_write = services.search("0d03")
# Need to request 100 + 3 extra bytes for ATT packet header
self.request_mtu(103)
def on_mtu_changed(self, mtu, status):
if status == GATT_SUCCESS and mtu == 103:
Logger.info("MTU changed: now it is possible to send 100 bytes at once")
self.write_characteristic(self.characteristic_to_write, range(100))
else:
Logger.error("MTU not changed: mtu=%d, status=%d", mtu, status)
def on_characteristic_write(self, characteristic, status):
if status == GATT_SUCCESS:
Logger.info("Characteristic write succeed")
else:
Logger.error("Write status: %d", status)
class MTUApp(App):
def build(self):
BLESender()
return Widget()
if __name__ == '__main__':
MTUApp().run()

View file

@ -0,0 +1,27 @@
[app]
title = Multiple BLE devices
version = 1.0
package.name = multidevs
package.domain = test.able
source.dir = .
source.include_exts = py,png,jpg,kv,atlas
requirements = python3,kivy,android,able_recipe
android.accept_sdk_license = True
android.permissions =
BLUETOOTH,
BLUETOOTH_ADMIN,
BLUETOOTH_SCAN,
BLUETOOTH_CONNECT,
BLUETOOTH_ADVERTISE,
ACCESS_FINE_LOCATION
# android.api = 31
# android.minapi = 31
[buildozer]
warn_on_root = 1
# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

View file

@ -0,0 +1,96 @@
"""Scan for devices with name "KivyBLETest",
connect and periodically read connected devices RSSI.
Multiple `BluetoothDispatcher` objects are used:
one for the scanning process and one for every connected device.
"""
from able import GATT_SUCCESS, BluetoothDispatcher
from able.filters import DeviceNameFilter
from kivy.app import App
from kivy.clock import Clock
from kivy.logger import Logger
from kivy.uix.label import Label
class DeviceDispatcher(BluetoothDispatcher):
"""Dispatcher to control a single BLE device."""
def __init__(self, device: "BluetoothDevice"):
super().__init__()
self._device = device
self._address: str = device.getAddress()
self._name: str = device.getName() or ""
@property
def title(self) -> str:
return f"<{self._address}><{self._name}>"
def on_connection_state_change(self, status: int, state: int):
if status == GATT_SUCCESS and state:
Logger.info(f"Device: {self.title} connected")
else:
Logger.info(f"Device: {self.title} disconnected. {status=}, {state=}")
self.close_gatt()
Clock.schedule_once(callback=lambda dt: self.reconnect(), timeout=15)
def on_rssi_updated(self, rssi: int, status: int):
Logger.info(f"Device: {self.title} RSSI: {rssi}")
def periodically_update_rssi(self):
"""
Clock callback to read
the signal strength indicator for a connected device.
"""
if self.gatt: # if device is connected
self.update_rssi()
def reconnect(self):
Logger.info(f"Device: {self.title} try to reconnect ...")
self.connect_gatt(self._device)
def start(self):
"""Start connection to device."""
if not self.gatt:
self.connect_gatt(self._device)
Clock.schedule_interval(
callback=lambda dt: self.periodically_update_rssi(), timeout=5
)
class ScannerDispatcher(BluetoothDispatcher):
"""Dispatcher to control the scanning process."""
def __init__(self):
super().__init__()
# Stores connected devices addresses
self._devices: dict[str, DeviceDispatcher] = {}
def on_scan_started(self, success: bool):
if success:
Logger.info("Scan: started")
else:
Logger.error("Scan: error on start")
def on_scan_completed(self):
Logger.info("Scan: completed")
def on_device(self, device, rssi, advertisement):
address = device.getAddress()
if address not in self._devices:
# Create dispatcher instance for a new device
dispatcher = DeviceDispatcher(device)
# Remember address,
# to avoid multiple dispatchers creation for this device
self._devices[address] = dispatcher
Logger.info(f"Scan: device <{address}> added")
dispatcher.start()
class MultiDevicesApp(App):
def build(self):
ScannerDispatcher().start_scan(filters=[DeviceNameFilter("KivyBLETest")])
return Label(text=self.name)
if __name__ == "__main__":
MultiDevicesApp().run()

View file

@ -0,0 +1,27 @@
[app]
title = BLE advertising service
version = 1.1
package.name = advservice
package.domain = test.able
source.dir = .
source.include_exts = py,png,jpg,kv,atlas
android.permissions =
FOREGROUND_SERVICE,
BLUETOOTH,
BLUETOOTH_ADMIN,
BLUETOOTH_CONNECT,
BLUETOOTH_ADVERTISE
requirements = kivy==2.1.0,python3,able_recipe
services = Able:service.py:foreground
android.accept_sdk_license = True
# android.api = 31
# android.minapi = 31
[buildozer]
warn_on_root = 1
# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

View file

@ -0,0 +1,56 @@
"""Start advertising service."""
from able import BluetoothDispatcher, Permission, require_bluetooth_enabled
from jnius import autoclass
from kivy.app import App
from kivy.lang import Builder
kv = """
BoxLayout:
Button:
text: 'Start service'
on_press: app.ble_dispatcher.start_service()
Button:
text: 'Stop service'
on_press: app.ble_dispatcher.stop_service()
"""
class Dispatcher(BluetoothDispatcher):
@property
def service(self):
return autoclass("test.able.advservice.ServiceAble")
@property
def activity(self):
return autoclass("org.kivy.android.PythonActivity").mActivity
# Need to turn on the adapter, before service is started
@require_bluetooth_enabled
def start_service(self):
self.service.start(
self.activity,
# Pass UUID to advertise
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
)
App.get_running_app().stop() # Can close the app, service will continue running
def stop_service(self):
self.service.stop(self.activity)
class ServiceApp(App):
def build(self):
self.ble_dispatcher = Dispatcher(
# This app does not use device scanning,
# so the list of required permissions can be reduced
runtime_permissions=[
Permission.BLUETOOTH_CONNECT,
Permission.BLUETOOTH_ADVERTISE,
]
)
return Builder.load_string(kv)
if __name__ == "__main__":
ServiceApp().run()

View file

@ -0,0 +1,28 @@
"""Service to advertise data, while not stopped."""
import time
from os import environ
from able import BluetoothDispatcher
from able.advertising import (
Advertiser,
AdvertiseData,
ServiceUUID,
)
def main():
uuid = environ.get(
"PYTHON_SERVICE_ARGUMENT",
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
)
advertiser = Advertiser(
ble=BluetoothDispatcher(),
data=AdvertiseData(ServiceUUID(uuid)),
)
advertiser.start()
while True:
time.sleep(0xDEAD)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,29 @@
[app]
title = BLE scan dev service
version = 1.1
package.name = scanservice
package.domain = test.able
source.dir = .
source.include_exts = py,png,jpg,kv,atlas
android.permissions =
FOREGROUND_SERVICE,
BLUETOOTH,
BLUETOOTH_ADMIN,
BLUETOOTH_SCAN,
BLUETOOTH_CONNECT,
BLUETOOTH_ADVERTISE,
ACCESS_FINE_LOCATION
requirements = kivy==2.1.0,python3,able_recipe
services = Able:service.py:foreground
android.accept_sdk_license = True
# android.api = 31
# android.minapi = 31
[buildozer]
warn_on_root = 1
# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

View file

@ -0,0 +1,48 @@
"""Start BLE devices scaning service."""
from able import (
BluetoothDispatcher,
require_bluetooth_enabled,
)
from jnius import autoclass
from kivy.app import App
from kivy.lang import Builder
kv = """
BoxLayout:
Button:
text: 'Start service'
on_press: app.ble_dispatcher.start_service()
Button:
text: 'Stop service'
on_press: app.ble_dispatcher.stop_service()
"""
class Dispatcher(BluetoothDispatcher):
@property
def service(self):
return autoclass("test.able.scanservice.ServiceAble")
@property
def activity(self):
return autoclass("org.kivy.android.PythonActivity").mActivity
# Need to turn on the adapter and obtain permissions, before service is started
@require_bluetooth_enabled
def start_service(self):
self.service.start(self.activity, "")
App.get_running_app().stop() # Can close the app, service will continue to run
def stop_service(self):
self.service.stop(self.activity)
class ServiceApp(App):
def build(self):
self.ble_dispatcher = Dispatcher()
return Builder.load_string(kv)
if __name__ == "__main__":
ServiceApp().run()

View file

@ -0,0 +1,27 @@
"""Service to run BLE scan for 60 seconds,
and log each `on_device` event.
"""
import time
from able import BluetoothDispatcher
from kivy.logger import Logger
class BLE(BluetoothDispatcher):
def on_device(self, device, rssi, advertisement):
title = device.getName() or device.getAddress()
Logger.info("BLE Device found: %s", title)
def on_error(self, msg):
Logger.error("BLE Error %s", msg)
def main():
ble = BLE()
ble.start_scan()
time.sleep(60)
ble.stop_scan()
if __name__ == "__main__":
main()

View file

View file

@ -0,0 +1,34 @@
"""
Android Bluetooth Low Energy
"""
from pythonforandroid.recipe import PythonRecipe
from pythonforandroid.toolchain import current_directory, info, shprint
import sh
from os.path import join
class AbleRecipe(PythonRecipe):
name = 'able_recipe'
depends = ['python3', 'setuptools', 'android']
call_hostpython_via_targetpython = False
install_in_hostpython = True
def prepare_build_dir(self, arch):
build_dir = self.get_build_dir(arch)
assert build_dir.endswith(self.name)
shprint(sh.rm, '-rf', build_dir)
shprint(sh.mkdir, build_dir)
for filename in ('../../able', 'setup.py'):
shprint(sh.cp, '-a', join(self.get_recipe_dir(), filename),
build_dir)
def postbuild_arch(self, arch):
super(AbleRecipe, self).postbuild_arch(arch)
info('Copying able java class to classes build dir')
with current_directory(self.get_build_dir(arch.arch)):
shprint(sh.cp, '-a', join('able', 'src', 'org'),
self.ctx.javaclass_dir)
recipe = AbleRecipe()

View file

@ -0,0 +1,9 @@
from setuptools import setup
setup(
name='able',
version='0.0.0',
packages=['able', 'able.android'],
description='Bluetooth Low Energy for Android',
license='MIT',
)

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

@ -0,0 +1,135 @@
import os
import re
import shutil
from pathlib import Path
from setuptools import setup
from setuptools.command.install import install
main_ns = {}
with Path("able/version.py").open() as ver_file:
exec(ver_file.read(), main_ns)
with Path("README.rst").open() as readme_file:
long_description = readme_file.read()
class PathParser:
@property
def javaclass_dir(self):
path = self.build_dir / "javaclasses"
if not path.exists():
raise Exception(
"Java classes directory is not found. "
"Please report issue to: https://github.com/b3b/able/issues"
)
path = path / self.distribution_name
print(f"Java classes directory found: '{path}'.")
path.mkdir(parents=True, exist_ok=True)
return path
@property
def distribution_name(self):
path = self.python_path
while path.parent.name != "python-installs":
if len(path.parts) <= 1:
raise Exception(
"Distribution name is not found. "
"Please report issue to: https://github.com/b3b/able/issues"
)
path = path.parent
print(f"Distribution name found: '{path.name}'.")
return path.name
@property
def build_dir(self):
return self.python_installs_dir.parent
@property
def python_installs_dir(self):
path = self.python_path.parent
while path.name != "python-installs":
if len(path.parts) <= 1:
raise Exception(
"Python installs directory is not found. "
"Please report issue to: https://github.com/b3b/able/issues"
)
path = path.parent
return path
@property
def python_path(self):
cppflags = os.environ["CPPFLAGS"]
print(f"Searching for Python install directory in CPPFLAGS: '{cppflags}'")
match = re.search(r"-I(/[^\s]+/build/python-installs/[^/\s]+/)", cppflags)
if not match:
raise Exception("Can't find Python install directory.")
found_path = Path(match.group(1))
print("FOUND INSTALL DIRECTORY: "+found_path)
return found_path
class InstallRecipe(install):
"""Command to install `able` recipe,
copies Java files to distribution `javaclass` directory."""
def run(self):
if False and "ANDROIDAPI" not in os.environ:
raise Exception(
"This recipe should not be installed directly, "
"only with the buildozer tool."
)
# Find Java classes target directory from the environment
javaclass_dir = str(PathParser().javaclass_dir)
for java_file in (
"able/src/org/able/BLE.java",
"able/src/org/able/BLEAdvertiser.java",
"able/src/org/able/PythonBluetooth.java",
"able/src/org/able/PythonBluetoothAdvertiser.java",
):
shutil.copy(java_file, javaclass_dir)
install.run(self)
setup(
name="able_recipe",
version=main_ns["__version__"],
packages=["able", "able.android"],
description="Bluetooth Low Energy for Android",
long_description=long_description,
long_description_content_type="text/x-rst",
author="b3b",
author_email="ash.b3b@gmail.com",
install_requires=[],
url="https://github.com/b3b/able",
project_urls={
"Changelog": "https://github.com/b3b/able/blob/master/CHANGELOG.rst",
},
# https://pypi.org/classifiers/
classifiers=[
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT License",
"Operating System :: Android",
"Topic :: System :: Networking",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
],
keywords="android ble bluetooth kivy",
license="MIT",
zip_safe=False,
cmdclass={
"install": InstallRecipe,
},
options={
"bdist_wheel": {
# Changing the wheel name
# to avoid installing a package from cache.
"plat_name": "unused-nocache",
},
},
)

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

@ -0,0 +1 @@
server

View file

@ -0,0 +1,193 @@
#:kivy 1.1.0
#: import Factory kivy.factory.Factory
#: import findall re.findall
<Caption@Label>:
padding_left: '4sp'
halign: 'left'
text_size: self.size
valign: 'middle'
<Value@Label>:
padding_left: '4sp'
halign: 'left'
text_size: self.size
valign: 'middle'
<ConnectByMACDialog@Popup>:
title: 'Connect by MAC address'
size_hint: None, None
size: '400sp', '120sp'
BoxLayout:
orientation: 'vertical'
pos: self.pos
size: root.size
TextInput:
size_hint_y: .5
hint_text: 'Device address'
input_filter: lambda value, _ : ''.join(findall('[0-9a-fA-F:]+', value)).upper()
multiline: False
text: app.device_address
on_text: app.device_address = self.text
BoxLayout:
orientation: 'horizontal'
size_hint_y: .5
Button:
text: 'Connect'
on_press: root.dismiss(), app.connect_by_mac_address()
Button:
text: 'Cancel'
on_press: root.dismiss()
<MainLayout>:
padding: '10sp'
BoxLayout:
orientation: 'horizontal'
GridLayout:
cols: 2
padding: '0sp'
spacing: '0sp'
orientation: 'lr-tb'
Caption:
text: 'Adapter:'
Value:
text: app.adapter_state
Caption:
text: 'State:'
Value:
text: app.state
halign: 'left'
valign: 'middle'
text_size: self.size
Caption:
text: 'Read test:'
Value:
text: app.test_string
Caption:
text: 'Notifications count:'
Value:
text: app.notification_value
Caption:
text: 'N packets sended:'
Value:
text: app.increment_count_value
Caption:
text: 'N packets delivered:'
Value:
text: app.counter_value
Caption:
text: 'Total transmission time:'
Value:
text: app.counter_total_time
BoxLayout:
spacing: '20sp'
orientation: 'vertical'
BoxLayout:
orientation: 'horizontal'
size_hint_y: .3
Button:
text: 'Scan and connect'
on_press: app.start_scan()
Button:
text: 'Connect by MAC address'
on_press: Factory.ConnectByMACDialog().open()
BoxLayout:
id: queue_box
orientation: 'vertical'
size_hint_y: .15
BoxLayout:
orientation: 'horizontal'
Caption:
text: 'Enable GATT autoconnect:'
CheckBox:
id: timeout_checkbox
active: app.autoconnect
on_active: app.autoconnect = self.active
BoxLayout:
orientation: 'horizontal'
size_hint_y: .2
spacing: 10
Button:
disabled: app.state != 'connected'
text: 'Read RSSI'
on_press: app.read_rssi()
Caption:
text: 'RSSI Value:'
Value:
text: app.rssi
ToggleButton:
disabled: app.state != 'connected'
text: "Enable notifications"
size_hint_y: .2
on_state: app.enable_notifications(self.state == 'down')
BoxLayout:
id: queue_box
orientation: 'vertical'
disabled: app.state != 'connected'
BoxLayout:
orientation: 'horizontal'
Caption:
text: 'Enable BLE queue timeout:'
CheckBox:
id: timeout_checkbox
active: app.queue_timeout_enabled
on_active: app.queue_timeout_enabled = self.active
BoxLayout:
orientation: 'horizontal'
Caption:
text: 'BLE queue timeout (ms):'
TextInput:
disabled: queue_box.disabled or not timeout_checkbox.active
input_filter: 'int'
multiline: False
text: app.queue_timeout
on_text: app.queue_timeout = self.text
BoxLayout:
Button:
text: 'Apply queue settings'
on_press: app.set_queue_settings()
BoxLayout:
disabled: app.state != 'connected'
orientation: 'vertical'
BoxLayout:
orientation: 'horizontal'
Caption:
text: 'Transmission interval (ms):'
TextInput:
input_filter: 'int'
multiline: False
text: app.incremental_interval
on_text: app.incremental_interval = self.text
BoxLayout:
orientation: 'horizontal'
Caption:
text: 'Packet count limit:'
TextInput:
input_filter: 'int'
multiline: False
text: app.counter_max
on_text: app.counter_max = self.text
padding_bottom: '100sp'
ToggleButton:
width: self.texture_size[0] + 50
text: "Enable transmission"
on_state: app.enable_counter(self.state == 'down')

View file

@ -0,0 +1,17 @@
[app]
title = BLE functions test
version = 1.0
package.name = kivy_ble_test
package.domain = org.kivy
source.dir = .
source.include_exts = py,png,jpg,kv,atlas
android.permissions = BLUETOOTH, BLUETOOTH_ADMIN, ACCESS_FINE_LOCATION
requirements = python3,kivy,android,able_recipe
# (str) Android's logcat filters to use
android.logcat_filters = *:S python:D
[buildozer]
warn_on_root = 1
# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

View file

@ -0,0 +1,245 @@
"""Connect to "KivyBLETest" server and test various BLE functions
"""
import time
from able import AdapterState, GATT_SUCCESS, BluetoothDispatcher
from kivy.app import App
from kivy.clock import Clock
from kivy.config import Config
from kivy.properties import BooleanProperty, StringProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.storage.jsonstore import JsonStore
Config.set('kivy', 'log_level', 'debug')
Config.set('kivy', 'log_enable', '1')
class MainLayout(BoxLayout):
pass
class BLETestApp(App):
ble = BluetoothDispatcher()
adapter_state = StringProperty('')
state = StringProperty('')
test_string = StringProperty('')
rssi = StringProperty('')
notification_value = StringProperty('')
counter_value = StringProperty('')
increment_count_value = StringProperty('')
incremental_interval = StringProperty('100')
counter_max = StringProperty('128')
counter_value = StringProperty('')
counter_state = StringProperty('')
counter_total_time = StringProperty('')
queue_timeout_enabled = BooleanProperty(True)
queue_timeout = StringProperty('1000')
device_name = StringProperty('KivyBLETest')
device_address = StringProperty('')
autoconnect = BooleanProperty(False)
store = JsonStore('bletestapp.json')
uids = {
'string': '0d01',
'counter_reset': '0d02',
'counter_increment': '0d03',
'counter_read': '0d04',
'notifications': '0d05'
}
def build(self):
if self.store.exists('device'):
self.device_address = self.store.get('device')['address']
else:
self.device_address = ''
return MainLayout()
def on_pause(self):
return True
def on_resume(self):
pass
def init(self):
self.set_queue_settings()
self.ble.bind(on_device=self.on_device)
self.ble.bind(on_scan_started=self.on_scan_started)
self.ble.bind(on_scan_completed=self.on_scan_completed)
self.ble.bind(on_bluetooth_adapter_state_change=self.on_bluetooth_adapter_state_change)
self.ble.bind(
on_connection_state_change=self.on_connection_state_change)
self.ble.bind(on_services=self.on_services)
self.ble.bind(on_characteristic_read=self.on_characteristic_read)
self.ble.bind(on_characteristic_changed=self.on_characteristic_changed)
self.ble.bind(on_rssi_updated=self.on_rssi_updated)
def start_scan(self):
if not self.state:
self.init()
self.state = 'scan_start'
self.ble.close_gatt()
self.ble.start_scan()
def connect_by_mac_address(self):
self.store.put('device', address=self.device_address)
if not self.state:
self.init()
self.state = 'try_connect'
self.ble.close_gatt()
try:
self.ble.connect_by_device_address(
self.device_address,
autoconnect=self.autoconnect,
)
except ValueError as exc:
self.state = str(exc)
def on_scan_started(self, ble, success):
self.state = 'scan' if success else 'scan_error'
def on_device(self, ble, device, rssi, advertisement):
if self.state != 'scan':
return
if device.getName() == self.device_name:
self.device = device
self.state = 'found'
self.ble.stop_scan()
def on_scan_completed(self, ble):
if self.device:
self.ble.connect_gatt(
self.device,
autoconnect=self.autoconnect,
)
def on_connection_state_change(self, ble, status, state):
if status == GATT_SUCCESS:
if state:
self.ble.discover_services()
else:
self.state = 'disconnected'
else:
self.state = 'connection_error'
def on_services(self, ble, status, services):
if status != GATT_SUCCESS:
self.state = 'services_error'
return
self.state = 'connected'
self.services = services
self.read_test_string(ble)
self.characteristics = {
'counter_increment': self.services.search(
self.uids['counter_increment']),
'counter_reset': self.services.search(
self.uids['counter_reset']),
}
def on_bluetooth_adapter_state_change(self, ble, state):
self.adapter_state = AdapterState(state).name
def read_rssi(self):
self.rssi = '...'
result = self.ble.update_rssi()
def on_rssi_updated(self, ble, rssi, status):
self.rssi = str(rssi) if status == GATT_SUCCESS else f"Bad status: {status}"
def read_test_string(self, ble):
characteristic = self.services.search(self.uids['string'])
if characteristic:
ble.read_characteristic(characteristic)
else:
self.test_string = 'not found'
def read_remote_counter(self):
characteristic = self.services.search(self.uids['counter_read'])
if characteristic:
self.ble.read_characteristic(characteristic)
else:
self.counter_value = 'error'
def enable_notifications(self, enable):
if enable:
self.notification_value = '0'
characteristic = self.services.search(self.uids['notifications'])
if characteristic:
self.ble.enable_notifications(characteristic, enable)
else:
self.notification_value = 'error'
def enable_counter(self, enable):
if enable:
self.counter_state = 'init'
interval = int(self.incremental_interval) * .001
Clock.schedule_interval(self.counter_next, interval)
else:
Clock.unschedule(self.counter_next)
if self.counter_state != 'stop':
self.counter_state = 'stop'
self.read_remote_counter()
def counter_next(self, dt):
if self.counter_state == 'init':
self.counter_started_time = time.time()
self.counter_total_time = ''
self.reset_remote_counter()
self.increment_remote_counter()
elif self.counter_state == 'enabled':
if int(self.increment_count_value) < int(self.counter_max):
self.increment_remote_counter()
else:
self.enable_counter(False)
def reset_remote_counter(self):
self.increment_count_value = '0'
self.counter_value = ''
self.ble.write_characteristic(self.characteristics['counter_reset'], [])
self.counter_state = 'enabled'
def on_characteristic_read(self, ble, characteristic, status):
uuid = characteristic.getUuid().toString()
if self.uids['string'] in uuid:
self.update_string_value(characteristic, status)
elif self.uids['counter_read'] in uuid:
self.counter_total_time = str(
time.time() - self.counter_started_time)
self.update_counter_value(characteristic, status)
def update_string_value(self, characteristic, status):
result = 'ERROR'
if status == GATT_SUCCESS:
value = characteristic.getStringValue(0)
if value == 'test':
result = 'OK'
self.test_string = result
def increment_remote_counter(self):
characteristic = self.characteristics['counter_increment']
self.ble.write_characteristic(characteristic, [])
prev_value = int(self.increment_count_value)
self.increment_count_value = str(prev_value + 1)
def update_counter_value(self, characteristic, status):
if status == GATT_SUCCESS:
self.counter_value = characteristic.getStringValue(0)
else:
self.counter_value = 'ERROR'
def set_queue_settings(self):
self.ble.set_queue_timeout(None if not self.queue_timeout_enabled
else int(self.queue_timeout) * .001)
def on_characteristic_changed(self, ble, characteristic):
uuid = characteristic.getUuid().toString()
if self.uids['notifications'] in uuid:
prev_value = self.notification_value
value = int(characteristic.getStringValue(0))
if (prev_value == 'error') or (value != int(prev_value) + 1):
value = 'error'
self.notification_value = str(value)
if __name__ == '__main__':
BLETestApp().run()

View file

@ -0,0 +1,95 @@
// +build
// based on https://github.com/paypal/gatt/blob/master/examples/server.go
package main
import (
"fmt"
"log"
"time"
"github.com/paypal/gatt"
"github.com/paypal/gatt/linux/cmd"
)
var DefaultServerOptions = []gatt.Option{
gatt.LnxMaxConnections(1),
gatt.LnxDeviceID(-1, false),
gatt.LnxSetAdvertisingParameters(&cmd.LESetAdvertisingParameters{
AdvertisingIntervalMin: 0x04ff,
AdvertisingIntervalMax: 0x04ff,
AdvertisingChannelMap: 0x7,
}),
}
func NewTestPythonService() *gatt.Service {
n := 0
s := gatt.NewService(gatt.MustParseUUID("16fe0d00-c111-11e3-b8c8-0002a5d5c51b"))
s.AddCharacteristic(gatt.MustParseUUID("16fe0d01-c111-11e3-b8c8-0002a5d5c51b")).HandleReadFunc(
func(rsp gatt.ResponseWriter, req *gatt.ReadRequest) {
n = 0
log.Println("Echo")
fmt.Fprintf(rsp, "test")
})
s.AddCharacteristic(gatt.MustParseUUID("16fe0d02-c111-11e3-b8c8-0002a5d5c51b")).HandleWriteFunc(
func(r gatt.Request, data []byte) (status byte) {
n = 0
log.Println("Reset counter")
return gatt.StatusSuccess
})
s.AddCharacteristic(gatt.MustParseUUID("16fe0d03-c111-11e3-b8c8-0002a5d5c51b")).HandleWriteFunc(
func(r gatt.Request, data []byte) (status byte) {
n++
log.Println("Increment counter")
return gatt.StatusSuccess
})
s.AddCharacteristic(gatt.MustParseUUID("16fe0d04-c111-11e3-b8c8-0002a5d5c51b")).HandleReadFunc(
func(rsp gatt.ResponseWriter, req *gatt.ReadRequest) {
log.Println("Response counter: ", n)
fmt.Fprintf(rsp, "%d", n)
})
s.AddCharacteristic(gatt.MustParseUUID("16fe0d05-c111-11e3-b8c8-0002a5d5c51b")).HandleNotifyFunc(
func(r gatt.Request, n gatt.Notifier) {
log.Println("Notifications enabled")
cnt := 1
for !n.Done() {
fmt.Fprintf(n, "%d", cnt)
cnt++
time.Sleep(100 * time.Millisecond)
}
log.Println("Notifications disabled")
})
return s
}
func main() {
d, err := gatt.NewDevice(DefaultServerOptions...)
if err != nil {
log.Fatalf("Failed to open device, err: %s", err)
}
d.Handle(
gatt.CentralConnected(func(c gatt.Central) { fmt.Println("Connect: ", c.ID()) }),
gatt.CentralDisconnected(func(c gatt.Central) { fmt.Println("Disconnect: ", c.ID()) }),
)
onStateChanged := func(d gatt.Device, s gatt.State) {
fmt.Printf("State: %s\n", s)
switch s {
case gatt.StatePoweredOn:
s1 := NewTestPythonService()
d.AddService(s1)
d.AdvertiseNameAndServices("KivyBLETest", []gatt.UUID{s1.UUID()})
default:
}
}
d.Init(onStateChanged)
select {}
}

View file

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

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

View file

@ -0,0 +1,43 @@
---
jupyter:
jupytext:
formats: ipynb,md
text_representation:
extension: .md
format_name: markdown
format_version: '1.3'
jupytext_version: 1.11.2
kernelspec:
display_name: Python 3
language: python
name: python3
---
```python
from time import sleep
%load_ext pythonhere
%connect-there
```
```python
%%there
from dataclasses import dataclass, field
from typing import List
from jnius import autoclass, cast
from able.android.dispatcher import (
BluetoothDispatcher
)
from able.scan_settings import (
ScanSettings,
ScanSettingsBuilder
)
@dataclass
class Results:
started: bool = None
completed: bool = None
devices: List = field(default_factory=lambda: [])
```

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

@ -0,0 +1,22 @@
#!/bin/bash
set -ex
name="$1"
command="${2}"
command="${command:=test}"
cat "${name}.md" |
jupytext --execute --to ipynb |
jupyter nbconvert --stdin --no-input --to asciidoc --output "${name}"
cat "${name}".asciidoc
if [ "${command}" = "test" ]; then
diff "${name}.asciidoc" "${name}.expected"
elif [ "${command}" = "record" ]; then
cp "${name}".asciidoc "${name}".expected
else
echo "Unknown command: ${command}"
exit 1
fi

View file

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

View file

@ -0,0 +1,26 @@
[[setup]]
= Setup
[[run-ble-devices-scan]]
= Run BLE devices scan
----
Started: None Completed: None
----
[[check-that-scan-started-and-completed]]
= Check that scan started and completed
----
Started: 1 Completed: 1
----
[[check-that-testing-device-was-discovered]]
= Check that testing device was discovered
----
True
----

View file

@ -0,0 +1,74 @@
---
jupyter:
jupytext:
formats: ipynb,md
text_representation:
extension: .md
format_name: markdown
format_version: '1.3'
jupytext_version: 1.11.2
kernelspec:
display_name: Python 3
language: python
name: python3
---
# Setup
```python
%run init.ipynb
```
```python
%%there
class BLE(BluetoothDispatcher):
def on_scan_started(self, success):
results.started = success
def on_scan_completed(self):
results.completed = 1
def on_device(self, device, rssi, advertisement):
results.devices.append(device)
ble = BLE()
```
# Run BLE devices scan
```python
%%there
results = Results()
print(f"Started: {results.started} Completed: {results.completed}")
ble.start_scan()
```
```python
sleep(10)
```
```python
%%there
ble.stop_scan()
```
```python
sleep(2)
```
# Check that scan started and completed
```python
%%there
print(f"Started: {results.started} Completed: {results.completed}")
```
# Check that testing device was discovered
```python
%%there
print(
"KivyBLETest" in [dev.getName() for dev in results.devices]
)
```

View file

@ -0,0 +1,72 @@
[[setup]]
= Setup
[[test-device-is-found-with-scan-filters-set]]
= Test device is found with scan filters set
----
{'KivyBLETest'}
----
[[test-device-is-not-found-filtered-out-by-name]]
= Test device is not found: filtered out by name
----
[]
----
[[test-scan-filter-mathes]]
= Test scan filter mathes
----
EmptyFilter() True
EmptyFilter() True
EmptyFilter() True
----
----
DeviceAddressFilter(address='AA:AA:AA:AA:AA:AA') True
DeviceAddressFilter(address='AA:AA:AA:AA:AA:AB') False
AA is not a valid Bluetooth address
----
----
DeviceNameFilter(name='KivyBLETest') True
DeviceNameFilter(name='KivyBLETes') False
----
----
ManufacturerDataFilter(id=76, data=[], mask=None) False
ManufacturerDataFilter(id=76, data=[], mask=None) True
ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 229], mask=None) True
ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=None) False
ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=[255, 255, 255, 255, 255, 255, 0]) True
ManufacturerDataFilter(id=76, data=[2, 21, 141, 166, 131, 214, 170], mask=[255, 255, 255, 255, 255, 255, 255]) False
ManufacturerDataFilter(id=76, data=[2, 0, 141, 166, 131], mask=[255, 0, 255, 255, 255]) True
ManufacturerDataFilter(id=76, data=b'\x02\x15', mask=None) True
ManufacturerDataFilter(id=76, data=b'\x02\x16', mask=None) False
----
----
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[], mask=None) True
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fc', data=[], mask=None) False
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[34], mask=None) True
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=None) False
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=[240]) True
ServiceDataFilter(uid='0000180f-0000-1000-8000-00805f9b34fb', data=[33], mask=[15]) False
----
----
ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51B', mask=None) True
ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask=None) False
ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask='ffffffff-ffff-ffff-ffff-ffffffffffff') False
ServiceUUIDFilter(uid='16fe0d00-c111-11e3-b8c8-0002a5d5c51C', mask='ffffffff-ffff-ffff-ffff-fffffffffff0') True
----

View file

@ -0,0 +1,222 @@
---
jupyter:
jupytext:
formats: ipynb,md
text_representation:
extension: .md
format_name: markdown
format_version: '1.3'
jupytext_version: 1.11.2
kernelspec:
display_name: Python 3
language: python
name: python3
---
# Setup
```python
%run init.ipynb
```
```python
%%there
from able.filters import *
class BLE(BluetoothDispatcher):
def on_scan_started(self, success):
results.started = success
def on_scan_completed(self):
results.completed = 1
def on_device(self, device, rssi, advertisement):
results.devices.append(device)
ble = BLE()
```
```python
%%there
BluetoothDevice = autoclass("android.bluetooth.BluetoothDevice")
ScanResult = autoclass("android.bluetooth.le.ScanResult")
ScanRecord = autoclass("android.bluetooth.le.ScanRecord")
Parcel = autoclass("android/os/Parcel")
ParcelUuid = autoclass('android.os.ParcelUuid')
def filter_matches(f, scan_result):
print(f, f.build().matches(scan_result))
def mock_device():
"""Return BluetoothDevice instance with address=AA:AA:AA:AA:AA:AA"""
device_data = [17, 0, 0, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65, 0, 58, 0, 65, 0, 65]
p = Parcel.obtain()
p.unmarshall(device_data, 0, len(device_data))
p.setDataPosition(0)
return BluetoothDevice.CREATOR.createFromParcel(p)
def mock_scan_result(record):
return ScanResult(mock_device(), ScanRecord.parseFromBytes(record), -33, 1633954394000)
def mock_test_app_scan_result():
return mock_scan_result(
[2, 1, 6, 17, 6, 27, 197, 213, 165, 2, 0, 200, 184, 227, 17, 17, 193, 0, 13,
254, 22, 12, 9, 75, 105, 118, 121, 66, 76, 69, 84, 101, 115, 116,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
)
def mock_beacon_scan_result():
"""0x4C, # Apple Manufacturer ID
bytes([
0x2, # SubType: Custom Manufacturer Data
0x15 # Subtype lenth
]) +
uuid + # UUID of beacon: UUID=8da683d6-e574-4a2e-bb9b-d83f2d05fc12
bytes([
0, 15, # Major value
0, 1, # Minor value
10 # RSSI, dBm at 1m
])
"""
return mock_scan_result(bytes.fromhex('1AFF4C0002158DA683D6E5744A2EBB9BD83F2D05FC12000F00010A'))
def mock_battery_scan_result():
"""Battery ("0000180f-0000-1000-8000-00805f9b34fb" or "180f" in short form)
service data: 34% (0x22)
"""
return mock_scan_result(bytes.fromhex('04160F1822'))
beacon = mock_beacon_scan_result()
battery = mock_battery_scan_result()
testapp = mock_test_app_scan_result()
```
# Test device is found with scan filters set
```python
%%there
results = Results()
ble.start_scan(filters=[
DeviceNameFilter("KivyBLETest") & ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51b"),
])
```
```python
sleep(10)
```
```python
%%there
ble.stop_scan()
```
```python
sleep(2)
```
```python
%%there
print(set([dev.getName() for dev in results.devices]))
```
# Test device is not found: filtered out by name
```python
%%there
results = Results()
ble.start_scan(filters=[DeviceNameFilter("No-such-device-8458e2e35158")])
```
```python
sleep(10)
```
```python
%%there
ble.stop_scan()
```
```python
sleep(2)
```
```python
%%there
print(results.devices)
```
# Test scan filter mathes
```python
%%there
filter_matches(EmptyFilter(), testapp)
filter_matches(EmptyFilter(), beacon)
filter_matches(EmptyFilter(), battery)
```
```python
%%there
filter_matches(DeviceAddressFilter("AA:AA:AA:AA:AA:AA"), testapp)
filter_matches(DeviceAddressFilter("AA:AA:AA:AA:AA:AB"), testapp)
try:
filter_matches(DeviceAddressFilter("AA"), testapp)
except Exception as exc:
print(exc)
```
```python
%%there
filter_matches(DeviceNameFilter("KivyBLETest"), testapp)
filter_matches(DeviceNameFilter("KivyBLETes"), testapp)
```
```python
%%there
filter_matches(ManufacturerDataFilter(0x4c, []), testapp)
filter_matches(ManufacturerDataFilter(0x4c, []), beacon)
filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xe5]), beacon)
filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa]), beacon)
filter_matches(
ManufacturerDataFilter(0x4c,
[0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa],
[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]),
beacon
)
filter_matches(
ManufacturerDataFilter(0x4c,
[0x2, 0x15, 0x8d, 0xa6, 0x83, 0xd6, 0xaa],
[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
beacon
)
filter_matches(ManufacturerDataFilter(0x4c, [0x2, 0, 0x8d, 0xa6, 0x83], [0xff, 0, 0xff, 0xff, 0xff]), beacon)
filter_matches(ManufacturerDataFilter(0x4c, b'\x02\x15'), beacon)
filter_matches(ManufacturerDataFilter(0x4c, b'\x02\x16'), beacon)
```
```python
%%there
filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", []), battery)
filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fc", []), battery)
filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x22]), battery)
filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21]), battery)
filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21], mask=[0xf0]), battery)
filter_matches(ServiceDataFilter("0000180f-0000-1000-8000-00805f9b34fb", [0x21], mask=[0x0f]), battery)
```
```python
%%there
filter_matches(ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51B"), testapp)
filter_matches(ServiceUUIDFilter("16fe0d00-c111-11e3-b8c8-0002a5d5c51C"), testapp)
filter_matches(ServiceUUIDFilter(
"16fe0d00-c111-11e3-b8c8-0002a5d5c51C",
"ffffffff-ffff-ffff-ffff-ffffffffffff"
), testapp)
filter_matches(ServiceUUIDFilter(
"16fe0d00-c111-11e3-b8c8-0002a5d5c51C",
"ffffffff-ffff-ffff-ffff-fffffffffff0"
), testapp)
```

View file

@ -0,0 +1,27 @@
[[setup]]
= Setup
[[run-scan_mode_low_power]]
= Run SCAN_MODE_LOW_POWER
----
True
----
[[run-scan_mode_low_latency]]
= Run SCAN_MODE_LOW_LATENCY
----
True
----
[[check-that-received-advertisement-count-is-greater-with-scan_mode_low_latency]]
= Check that received advertisement count is greater with
SCAN_MODE_LOW_LATENCY
----
True
----

View file

@ -0,0 +1,105 @@
---
jupyter:
jupytext:
formats: ipynb,md
text_representation:
extension: .md
format_name: markdown
format_version: '1.3'
jupytext_version: 1.11.2
kernelspec:
display_name: Python 3
language: python
name: python3
---
# Setup
```python
%run init.ipynb
```
```python
%%there
class BLE(BluetoothDispatcher):
def on_scan_started(self, success):
results.started = success
def on_scan_completed(self):
results.completed = 1
def on_device(self, device, rssi, advertisement):
results.devices.append(device)
def get_advertisemnt_count():
return len([dev for dev in results.devices if dev.getName() == "KivyBLETest"])
ble = BLE()
```
# Run SCAN_MODE_LOW_POWER
```python
%%there
results = Results()
ble.start_scan(
settings=ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
)
```
```python
sleep(20)
```
```python
%%there
ble.stop_scan()
```
```python
sleep(2)
```
```python
%%there
low_power_advertisement_count = get_advertisemnt_count()
print(low_power_advertisement_count > 0)
```
# Run SCAN_MODE_LOW_LATENCY
```python
%%there
results = Results()
ble.start_scan(
settings=ScanSettingsBuilder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
)
```
```python
sleep(20)
```
```python
%%there
ble.stop_scan()
```
```python
sleep(2)
```
```python
%%there
low_latency_advertisement_count = get_advertisemnt_count()
print(low_latency_advertisement_count > 0)
```
# Check that received advertisement count is greater with SCAN_MODE_LOW_LATENCY
```python
%%there
print(low_latency_advertisement_count - low_power_advertisement_count > 2)
```

View file

@ -0,0 +1,81 @@
import pytest
from able.adapter import (
AdapterManager,
require_bluetooth_enabled,
set_adapter_failure_rollback,
)
from able.android.dispatcher import BluetoothDispatcher
@pytest.fixture
def manager(mocker):
return AdapterManager(mocker.Mock(), ..., [])
def test_operation_executed(mocker, manager):
operation = mocker.Mock()
logger = mocker.patch("able.adapter.Logger")
manager.execute(operation)
operation.assert_called_once()
logger.exception.assert_not_called()
def test_operation_failed_as_expected(mocker, manager):
manager.check_permissions = mocker.Mock(return_value=False)
expected = Exception("expected")
operation = mocker.Mock(side_effect=expected)
logger = mocker.patch("able.adapter.Logger")
manager.execute(operation)
operation.assert_not_called()
manager.check_permissions = mocker.Mock(return_value=True)
manager.execute_operations()
operation.assert_called_once()
logger.exception.assert_called_once_with(expected)
def test_operations_executed(mocker, manager):
operations = [mocker.Mock(), mocker.Mock()]
manager.operations = operations.copy()
manager.check_permissions = mocker.Mock(return_value=False)
manager.execute_operations()
# permissions not granted = > suspended
calls = [operation.call_count for operation in manager.operations]
assert calls == [0, 0]
assert manager.operations == operations
# one more operation requested
manager.execute(next_operation := mocker.Mock())
assert [operation.call_count for operation in manager.operations] == [0, 0, 0]
assert manager.operations == operations + [next_operation]
manager.check_permissions = mocker.Mock(return_value=True)
manager.execute_operations()
assert not manager.operations
assert [operation.call_count for operation in operations + [next_operation]] == [
1,
1,
1,
]
def test_rollback_performed(mocker, manager):
handlers = [mocker.Mock(), mocker.Mock()]
operations = [mocker.Mock(), mocker.Mock()]
manager.operations = operations.copy()
manager.rollback_handlers = handlers.copy()
manager.rollback()
assert not manager.rollback_handlers
assert not manager.operations
assert [operation.call_count for operation in operations] == [0, 0]
assert [operation.call_count for operation in handlers] == [1, 1]

View file

@ -0,0 +1,34 @@
import unittest
import mock
from able.queue import ble_task
class TestBLETask(unittest.TestCase):
def setUp(self):
self.queue = mock.Mock()
self.task_called = None
@ble_task
def increment(self, a=1, b=0):
self.task_called = a + b
def test_method_not_executed(self):
self.increment()
self.assertEqual(self.task_called, None)
def test_task_enqued(self):
self.increment()
self.assertTrue(self.queue.enque.called)
def test_task_default_arguments(self):
self.increment()
task = self.queue.enque.call_args[0][0]
task()
self.assertEqual(self.task_called, 1)
def test_task_arguments_passed(self):
self.increment(200, 11)
task = self.queue.enque.call_args[0][0]
task()
self.assertEqual(self.task_called, 211)

View file

@ -0,0 +1,47 @@
import pytest
from able.android.dispatcher import BluetoothDispatcher
@pytest.fixture
def ble(mocker):
mocker.patch("able.android.dispatcher.PythonBluetooth")
ble = BluetoothDispatcher()
ble._ble = mocker.Mock()
ble.on_scan_started = mocker.Mock()
return ble
def test_adapter_returned(mocker, ble):
manager = ble._adapter_manager
manager.check_permissions = mocker.Mock(return_value=False)
assert not ble.adapter
assert not ble.adapter
manager.check_permissions = mocker.Mock(return_value=True)
assert ble.adapter
def test_start_scan_executed(ble):
manager = ble._adapter_manager
assert manager
ble.start_scan()
ble._ble.startScan.assert_called_once()
def test_start_scan_failed_as_expected(mocker, ble):
manager = ble._adapter_manager
manager.check_permissions = mocker.Mock(return_value=False)
ble.start_scan()
ble._ble.startScan.assert_not_called()
assert len(manager.operations) == 1
assert len(manager.rollback_handlers) == 1
manager.on_runtime_permissions(permissions=[...], grant_results=[False])
ble.on_scan_started.assert_called_once_with(success=False)
assert len(manager.operations) == 0
assert len(manager.rollback_handlers) == 0

View file

@ -0,0 +1,50 @@
import pytest
import able.filters as filters
@pytest.fixture
def java_builder(mocker):
instance = mocker.Mock()
mocker.patch("able.filters.ScanFilterBuilder", return_value=instance)
return instance
def test_filter_builded(java_builder):
filters.Filter().build()
assert java_builder.build.call_count == 1
def test_builder_method_called(java_builder):
f = filters.DeviceNameFilter("test")
f.build()
assert java_builder.method_calls == [
("setDeviceName", ("test",)),
("build", )
]
def test_filters_combined(java_builder):
f = filters.DeviceNameFilter("test") & (
filters.DeviceAddressFilter("AA:AA:AA:AA:AA:AA") &
filters.ManufacturerDataFilter("test-id", [1, 2, 3])
)
f.build()
assert java_builder.method_calls == [
("setDeviceName", ("test",)),
("setDeviceAddress", ("AA:AA:AA:AA:AA:AA",)),
("setManufacturerData", ("test-id", [1, 2, 3])),
("build", )
]
def test_combine_same_type_exception(java_builder):
with pytest.raises(ValueError, match="cannot combine filters of the same type"):
f = filters.DeviceNameFilter("test") & (
filters.DeviceAddressFilter("AA:AA:AA:AA:AA:AA") &
filters.DeviceNameFilter("test2")
)

View file

@ -0,0 +1,30 @@
from pathlib import Path
import pytest
@pytest.fixture
def parser(mocker):
mocker.patch("setuptools.setup")
from setup import PathParser
return PathParser()
@pytest.mark.parametrize(
("cppflags", "expected"),
[
(
"-I/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/alert_mi/",
"/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/javaclasses/alert_mi",
),
(
"-DANDROID -I/home/user/.buildozer/android/platform/android-ndk-r25b/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include -I/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/python-installs/alert_mi/arm64-v8a/include/python3.9",
"/media/able/examples/alert/.buildozer/android/platform/build-arm64-v8a_armeabi-v7a/build/javaclasses/alert_mi",
),
],
)
def test_javaclass_dir_found(mocker, parser, cppflags, expected):
mocker.patch("os.environ", {"CPPFLAGS": cppflags})
mocker.patch("pathlib.Path.exists", return_value=True)
mocker.patch("pathlib.Path.mkdir")
assert parser.javaclass_dir == Path(expected)

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

View file

@ -0,0 +1,9 @@
from setuptools import setup
setup(
name='able',
version='0.0.0',
packages=['able', 'able.android'],
description='Bluetooth Low Energy for Android',
license='MIT',
)

View file

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

View 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

View file

@ -1,5 +1,5 @@
--- ffpyplayer/setup.py 2024-06-02 11:10:49.691183467 +0530
+++ ffpyplayer.mod/setup.py 2024-06-02 11:20:16.220966873 +0530
--- ffpyplayer/setup.py 2024-06-02 11:10:49.691183467 +0530
+++ ffpyplayer.mod/setup.py 2024-06-02 11:20:16.220966873 +0530
@@ -27,12 +27,6 @@
# This sets whether or not Cython gets added to setup_requires.
declare_cython = False

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

View 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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View 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:

View 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

View 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:

View 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

View 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

View 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

Some files were not shown because too many files have changed in this diff Show more