diff --git a/AUTHORS.rst b/AUTHORS.rst index b6eca00d..1227589d 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -24,6 +24,7 @@ Contributors * David Johansen * JP Hutchins * Bram Duvigneau +* Victor Chavez Sponsors -------- diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9c360d48..2dd98367 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,10 @@ and this project adheres to `Semantic Versioning str: + """The uuid of the Service containing this characteristic""" + return self.__svc.uuid + + @property + def service_handle(self) -> int: + """The integer handle of the Service containing this characteristic""" + return self.__svc.handle + + @property + def handle(self) -> int: + """The handle of this characteristic""" + return int(self.obj.handle) + + @property + def uuid(self) -> str: + """The uuid of this characteristic""" + return self.__uuid + + @property + def properties(self) -> List[str]: + """Properties of this characteristic""" + return self.__props + + @property + def descriptors(self) -> List[BleakGATTDescriptor]: + """List of descriptors for this characteristic""" + return self.__descriptors + + def get_descriptor( + self, specifier: Union[int, str, UUID] + ) -> Union[BleakGATTDescriptor, None]: + """Get a descriptor by handle (int) or UUID (str or uuid.UUID)""" + try: + if isinstance(specifier, int): + return next(filter(lambda x: x.handle == specifier, self.descriptors)) + else: + return next( + filter(lambda x: x.uuid == str(specifier), self.descriptors) + ) + except StopIteration: + return None + + def add_descriptor(self, descriptor: BleakGATTDescriptor): + """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. + + Should not be used by end user, but rather by `bleak` itself. + """ + self.__descriptors.append(descriptor) diff --git a/bleak/backends/bumble/client.py b/bleak/backends/bumble/client.py new file mode 100644 index 00000000..b55f8219 --- /dev/null +++ b/bleak/backends/bumble/client.py @@ -0,0 +1,293 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2024 Victor Chavez +""" +BLE Client for Bumble +""" + +import logging +import sys +import uuid +from functools import partial +from typing import Optional, Union + +from bumble.controller import Controller +from bumble.device import Connection, Device, Peer +from bumble.hci import HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, Address +from bumble.host import Host +from bumble.transport import Transport + +from bleak.backends.bumble.characteristic import BleakGATTCharacteristicBumble +from bleak.backends.bumble.descriptor import BleakGATTDescriptorBumble +from bleak.backends.bumble.service import BleakGATTServiceBumble + +from . import get_default_transport, get_link, start_transport + +if sys.version_info < (3, 12): + from typing_extensions import Buffer +else: + from collections.abc import Buffer + +from ...exc import BleakCharacteristicNotFoundError, BleakError +from ..characteristic import BleakGATTCharacteristic +from ..client import BaseBleakClient, NotifyCallback +from ..device import BLEDevice +from ..service import BleakGATTServiceCollection + +logger = logging.getLogger(__name__) +CLIENT_MAC = "F0:F1:F2:F3:F4:00" + + +class BleakClientBumble(BaseBleakClient, Device.Listener, Connection.Listener): + """Bumble class interface for BleakClient + + Args: + address_or_ble_device (`BLEDevice` or str): The Bluetooth address of the BLE peripheral to connect to or the `BLEDevice` object representing it. + + Keyword Args: + adapter (str): Bumble transport adapter to use. Defaults to `get_default_transport()`. + + """ + + def __init__( + self, + address_or_ble_device: Union[BLEDevice, str], + **kwargs, + ): + super(BleakClientBumble, self).__init__(address_or_ble_device, **kwargs) + self._dev = Device("client", address=Address(CLIENT_MAC)) + self._is_connected = False + self._connection = None + self._peer = None + self._dev.host = Host() + self._dev.host.controller = Controller("Client", link=get_link()) + self._dev.listener = self + self._adapter: Optional[Transport] = kwargs.get( + "adapter", get_default_transport() + ) + + @property + def mtu_size(self) -> int: + return self._connection.att_mtu + + async def connect(self, **kwargs) -> bool: + """Connect to the specified GATT server. + + Returns: + Boolean representing connection status. + + """ + await start_transport(self._adapter) + await self._dev.power_on() + await self._dev.connect(self.address) + + self.services = await self.get_services() + + async def disconnect(self) -> bool: + """Disconnect from the specified GATT server. + + Returns: + Boolean representing connection status. + + """ + await self._dev.disconnect( + self._connection, HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR + ) + + async def pair(self, *args, **kwargs) -> bool: + """Pair with the peripheral.""" + await self._peer.connection.pair() + + async def unpair(self) -> bool: + """Unpair with the peripheral.""" + raise NotImplementedError() + + @property + def is_connected(self) -> bool: + """Check connection status between this client and the server. + + Returns: + Boolean representing connection status. + + """ + return self._is_connected + + async def get_services(self, **kwargs) -> BleakGATTServiceCollection: + """Get all services registered for this GATT server. + + Returns: + A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree. + + """ + if not self.is_connected: + raise BleakError("Not connected") + + if self.services is not None: + return self.services + + new_services = BleakGATTServiceCollection() + self._peer = Peer(self._connection) + await self._peer.discover_services() + for service in self._peer.services: + new_services.add_service(BleakGATTServiceBumble(service)) + await service.discover_characteristics() + for characteristic in service.characteristics: + await characteristic.discover_descriptors() + new_services.add_characteristic( + BleakGATTCharacteristicBumble( + characteristic, lambda: self.mtu_size - 3, service + ) + ) + for descr in characteristic.descriptors: + new_services.add_descriptor( + BleakGATTDescriptorBumble(descr, characteristic) + ) + + return new_services + + async def read_gatt_char( + self, + char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], + **kwargs, + ) -> bytearray: + """Perform read operation on the specified GATT characteristic. + + Args: + char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to read from, + specified by either integer handle, UUID or directly by the + BleakGATTCharacteristic object representing it. + + Returns: + (bytearray) The read data. + + """ + if not isinstance(char_specifier, BleakGATTCharacteristic): + characteristic = self.services.get_characteristic(char_specifier) + else: + characteristic = char_specifier + if not characteristic: + raise BleakCharacteristicNotFoundError(char_specifier) + + value = await self._peer.read_characteristics_by_uuid(characteristic.obj.uuid) + logger.debug("Read Characteristic {0} : {1}".format(characteristic.uuid, value)) + return value + + async def read_gatt_descriptor(self, handle: int, **kwargs) -> bytearray: + """Perform read operation on the specified GATT descriptor. + + Args: + handle (int): The handle of the descriptor to read from. + + Returns: + (bytearray) The read data. + + """ + descriptor = self.services.get_descriptor(handle) + if not descriptor: + raise BleakError("Descriptor {} was not found!".format(handle)) + val = await descriptor.obj.read_value() + logger.debug("Read Descriptor {0} : {1}".format(handle, val)) + return val + + async def write_gatt_char( + self, + characteristic: BleakGATTCharacteristic, + data: Buffer, + response: bool, + ) -> None: + """ + Perform a write operation on the specified GATT characteristic. + + Args: + characteristic: The characteristic to write to. + data: The data to send. + response: If write-with-response operation should be done. + """ + await characteristic.obj.write_value(data) + logger.debug("Write Characteristic {0} : {1}".format(characteristic.uuid, data)) + + async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: + """Perform a write operation on the specified GATT descriptor. + + Args: + handle: The handle of the descriptor to read from. + data: The data to send (any bytes-like object). + + """ + descriptor = self.services.get_descriptor(handle) + if not descriptor: + raise BleakError("Descriptor {} was not found!".format(handle)) + await descriptor.obj.write_value(data) + logger.debug("Write Descriptor {0} : {1}".format(handle, data)) + + def __notify_handler(self, characteristic: BleakGATTCharacteristicBumble, value): + for sub in self._subs[characteristic.obj.uuid]: + sub(value) + + async def start_notify( + self, + characteristic: BleakGATTCharacteristic, + callback: NotifyCallback, + **kwargs, + ) -> None: + """ + Activate notifications/indications on a characteristic. + + Implementers should call the OS function to enable notifications or + indications on the characteristic. + + To keep things the same cross-platform, notifications should be preferred + over indications if possible when a characteristic supports both. + """ + if not self._subs.get(characteristic.obj.uuid): + self._subs[characteristic.obj.uuid] = [] + self._subs[characteristic.obj.uuid].append(callback) + await characteristic.obj.subscribe( + partial(self.__notify_handler, characteristic) + ) + + async def stop_notify( + self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID] + ) -> None: + """Deactivate notification/indication on a specified characteristic. + + Args: + char_specifier (BleakGATTCharacteristic, int, str or UUID): The characteristic to deactivate + notification/indication on, specified by either integer handle, UUID or + directly by the BleakGATTCharacteristic object representing it. + + """ + if not isinstance(char_specifier, BleakGATTCharacteristic): + characteristic = self.services.get_characteristic(char_specifier) + else: + characteristic = char_specifier + await characteristic.obj.unsubscribe() + self._subs.pop(characteristic.obj.uuid, None) + + # Listener callbacks + def on_advertisement(self, advertisement): + pass + + def on_inquiry_result(self, address, class_of_device, data, rssi): + pass + + def on_connection(self, connection): + self._is_connected = True + self._connection = connection + self._connection.listener = self + self._subs = {} + + def on_connection_failure(self, error): + pass + + def on_connection_request(self, bd_addr, class_of_device, link_type): + pass + + def on_characteristic_subscription( + self, connection, characteristic, notify_enabled, indicate_enabled + ): + pass + + def on_disconnection(self, reason): + self._is_connected = False + self._connection = None + self._subs = {} diff --git a/bleak/backends/bumble/descriptor.py b/bleak/backends/bumble/descriptor.py new file mode 100644 index 00000000..c442ec16 --- /dev/null +++ b/bleak/backends/bumble/descriptor.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2024 Victor Chavez + + +from bumble.gatt_client import CharacteristicProxy, DescriptorProxy + +from ..descriptor import BleakGATTDescriptor + + +class BleakGATTDescriptorBumble(BleakGATTDescriptor): + """GATT Descriptor implementation for Bumble backend.""" + + def __init__(self, obj: DescriptorProxy, characteristic: CharacteristicProxy): + super(BleakGATTDescriptorBumble, self).__init__(obj) + self.obj = obj + self.__characteristic = characteristic + + @property + def characteristic_uuid(self) -> str: + """UUID for the characteristic that this descriptor belongs to""" + return str(self.__characteristic.uuid) + + @property + def characteristic_handle(self) -> int: + """handle for the characteristic that this descriptor belongs to""" + return self.__characteristic.handle + + @property + def uuid(self) -> str: + """UUID for this descriptor""" + return str(self.obj.uuid) + + @property + def handle(self) -> int: + """Integer handle for this descriptor""" + return self.obj.handle diff --git a/bleak/backends/bumble/scanner.py b/bleak/backends/bumble/scanner.py new file mode 100644 index 00000000..ffe26467 --- /dev/null +++ b/bleak/backends/bumble/scanner.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2024 Victor Chavez + +import logging +from typing import List, Literal, Optional + +from bumble.controller import Controller +from bumble.core import AdvertisingData +from bumble.device import Advertisement, Device +from bumble.hci import Address +from bumble.host import Host +from bumble.transport import Transport + +from bleak.backends.bumble import get_default_transport, get_link, start_transport +from bleak.backends.scanner import ( + AdvertisementData, + AdvertisementDataCallback, + BaseBleakScanner, +) + +logger = logging.getLogger(__name__) + +SERVICE_UUID_TYPES = [ + AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS, + AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, +] + +SCANNER_MAC_ADDR = "F0:F1:F2:F3:F4:F5" + + +class BumbleScanner(BaseBleakScanner, Device.Listener): + def __init__( + self, + detection_callback: Optional[AdvertisementDataCallback], + service_uuids: Optional[List[str]], + scanning_mode: Literal["active", "passive"], + **kwargs, + ): + super(BumbleScanner, self).__init__(detection_callback, service_uuids) + self.device = Device("scanner_dev", address=Address(SCANNER_MAC_ADDR)) + + self.device.host = Host() + self.device.host.controller = Controller("Scanner", link=get_link()) + self.device.listener = self + self._scan_active = scanning_mode == "active" + self._adapter: Optional[Transport] = kwargs.get( + "adapter", get_default_transport() + ) + + def on_advertisement(self, advertisement: Advertisement): + service_uuids = [] + service_data = {} + local_name = advertisement.data.get(AdvertisingData.COMPLETE_LOCAL_NAME) + manuf_data = advertisement.data.get(AdvertisingData.MANUFACTURER_SPECIFIC_DATA) + for uuid_type in SERVICE_UUID_TYPES: + adv_uuids = advertisement.data.get(uuid_type) + if adv_uuids is None: + continue + for uuid in adv_uuids: + if uuid not in service_uuids: + service_uuids.append(str(uuid)) + + for service_data in advertisement.data.get_all(AdvertisingData.SERVICE_DATA): + service_uuid, data = service_data + service_data[str(service_uuid)] = data + + advertisement_data = AdvertisementData( + local_name=local_name, + manufacturer_data=manuf_data, + service_data=service_data, + service_uuids=service_uuids, + tx_power=advertisement.tx_power, + rssi=advertisement.rssi, + platform_data=(None, None), + ) + + device = self.create_or_update_device( + str(advertisement.address), + local_name, + {}, + advertisement_data, + ) + + self.call_detection_callbacks(device, advertisement_data) + + async def on_connection(self, connection): + pass + + async def start(self) -> None: + await start_transport(self._adapter) + await self.device.power_on() + await self.device.start_scanning(active=self._scan_active) + + async def stop(self) -> None: + await self.device.stop_scanning() + await self.device.power_off() + + def set_scanning_filter(self, **kwargs) -> None: + # Implement scanning filter setup + pass diff --git a/bleak/backends/bumble/service.py b/bleak/backends/bumble/service.py new file mode 100644 index 00000000..50a982c1 --- /dev/null +++ b/bleak/backends/bumble/service.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2024 Victor Chavez + +from typing import List + +from bumble.gatt_client import CharacteristicProxy, ServiceProxy + +from ... import normalize_uuid_str +from ..service import BleakGATTService +from .utils import uuid_bytes_to_str + + +class BleakGATTServiceBumble(BleakGATTService): + """GATT Characteristic implementation for the Bumble backend.""" + + def __init__(self, obj: ServiceProxy): + super().__init__(obj) + self.__characteristics = [] + uuid = uuid_bytes_to_str(obj.uuid.uuid_bytes) + self.__uuid = normalize_uuid_str(uuid) + + @property + def handle(self) -> int: + return self.obj.handle + + @property + def uuid(self) -> str: + return self.__uuid + + @property + def characteristics(self) -> List[CharacteristicProxy]: + """List of characteristics for this service""" + return self.__characteristics + + def add_characteristic(self, characteristic: CharacteristicProxy): + """Add a :py:class:`~BleakGATTCharacteristicWinRT` to the service. + + Should not be used by end user, but rather by `bleak` itself. + """ + self.__characteristics.append(characteristic) diff --git a/bleak/backends/bumble/utils.py b/bleak/backends/bumble/utils.py new file mode 100644 index 00000000..51553b61 --- /dev/null +++ b/bleak/backends/bumble/utils.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2024 Victor Chavez +def uuid_bytes_to_str(byte_data: bytes) -> str: + """ + Convert uuid bytes from bumble uuid to string + """ + # Convert bytes to hex string, reverse the order, and join back as a string + reversed_hex = "".join([f"{b:02X}" for b in byte_data[::-1]]) + return reversed_hex diff --git a/bleak/backends/client.py b/bleak/backends/client.py index ddf77f2f..297d761c 100644 --- a/bleak/backends/client.py +++ b/bleak/backends/client.py @@ -251,6 +251,11 @@ def get_platform_client_backend_type() -> Type[BaseBleakClient]: """ Gets the platform-specific :class:`BaseBleakClient` type. """ + if os.environ.get("BLEAK_BUMBLE") is not None: + from bleak.backends.bumble.client import BleakClientBumble + + return BleakClientBumble + if os.environ.get("P4A_BOOTSTRAP") is not None: from bleak.backends.p4android.client import BleakClientP4Android diff --git a/bleak/backends/scanner.py b/bleak/backends/scanner.py index eb0d71f0..5cbecaa2 100644 --- a/bleak/backends/scanner.py +++ b/bleak/backends/scanner.py @@ -312,6 +312,11 @@ def get_platform_scanner_backend_type() -> Type[BaseBleakScanner]: """ Gets the platform-specific :class:`BaseBleakScanner` type. """ + if os.environ.get("BLEAK_BUMBLE") is not None: + from bleak.backends.bumble.scanner import BumbleScanner + + return BumbleScanner + if os.environ.get("P4A_BOOTSTRAP") is not None: from bleak.backends.p4android.scanner import BleakScannerP4Android diff --git a/docs/backends/android.rst b/docs/backends/android.rst index 71f9d8c8..f405045e 100644 --- a/docs/backends/android.rst +++ b/docs/backends/android.rst @@ -1,7 +1,7 @@ Android backend =============== -For an example of building an android bluetooth app, see +For an example of building an android bluetooth app, see `the example folder `_ and its accompanying README file. diff --git a/docs/backends/bumble.rst b/docs/backends/bumble.rst new file mode 100644 index 00000000..d8b6514d --- /dev/null +++ b/docs/backends/bumble.rst @@ -0,0 +1,113 @@ +Bumble backend +=============== + +This backend adds support for the |bumble| Bluetooth Controller Stack from Google. +The backend enables support of multiple |bumble_transport| to communicate with +a physical or virtual HCI controller. + +Use cases for this backend are: + +1. Bluetooth Functional tests without Hardware. Example of Bluetooth stacks that + support virtualization are |android_emulator| and |zephyr|. +2. Virtual connection between different HCI Controllers that are not in the same + radio network (virtual or physical). + +.. note:: + + To select this backend in bleak, the environmental variable ``BLEAK_BUMBLE`` must be set. + +Examples +--------- + +The backend can be enabled for different |bumble_transport|. In the following subsections +information on how to use this backend with some applications is shown. + +Zephyr RTOS +~~~~~~~~~~~~ + +Zepyhr RTOS supports a |zephyr_virtual| over a TCP client. To connect your application with zephyr +you need to define a |tcp_server| transport for bumble. + +.. code-block:: python + + transport="tcp-server:127.0.0.1:1000" + scanner = BleakScanner(adapter=transport) + async for bd, ad in scanner.advertisement_data(): + client = BleakClient(bd,adapter=transport) + await client.connect() + +In the previous code snippet the bumble backend will create a TCP server on the localhost +at port 1000. + +.. note:: + + The Zephyr application must be compiled for the ``native/posix/64`` board. The Bumble + controller does not support all HCI LE Commands. For this reason the following configs + must be disabled in the Zephyr firmware: ``CONFIG_BT_EXT_ADV``, ``CONFIG_BT_AUTO_PHY_UPDATE``, + ``CONFIG_BT_HCI_ACL_FLOW_CONTROL``. + + +Android Emulator +~~~~~~~~~~~~~~~~ + +The |android_emulator| supports virtualization of the Bluetooth Controller +over gRPC with the android |netsim| tool. + +.. code-block:: python + + transport="android-netsim" + scanner = BleakScanner(adapter=transport) + async for bd, ad in scanner.advertisement_data(): + client = BleakClient(bd,adapter=transport) + await client.connect() + + +Other +~~~~~~~~~~~~ + +In general other |bumble_transport| can be used with bumble. The extra argument +``adapter`` can be used to set the desired transport. + +API +--- + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.bumble.scanner + :members: + + +Client +~~~~~~ + +.. automodule:: bleak.backends.bumble.client + :members: + + + +.. |bumble| raw:: html + Bumble + +.. |bumble_transport| html + transport protocols + + +.. |android_emulator| raw:: html + Android Emulator + +.. |zephyr| raw:: html + Zephyr RTOS + + +.. |zephyr_virtual| raw:: html + Virtual HCI + + +.. |tcp_server| raw:: html + TCP Server + + +.. |netsim| raw:: html + netsim + diff --git a/docs/backends/index.rst b/docs/backends/index.rst index 2afbb68d..df5bfe93 100644 --- a/docs/backends/index.rst +++ b/docs/backends/index.rst @@ -7,6 +7,7 @@ Bleak supports the following operating systems: * Linux distributions with BlueZ >= 5.43 (See :ref:`linux-backend` for more details) * OS X/macOS support via Core Bluetooth API, from at least version 10.11 * Partial Android support mostly using Python-for-Android/Kivy. +* Google´s Bumble BLE stack. These pages document platform specific differences from the interface API. @@ -19,6 +20,7 @@ Contents: linux macos android + bumble Shared Backend API ------------------