diff --git a/.github/workflows/format_and_lint.yml b/.github/workflows/format_and_lint.yml index 229379cc..14ca0402 100644 --- a/.github/workflows/format_and_lint.yml +++ b/.github/workflows/format_and_lint.yml @@ -21,4 +21,4 @@ jobs: - name: Lint with flake8 run: pipx run poetry run flake8 . --count --show-source --statistics - name: Build docs - run: READTHEDOCS=True pipx run poetry run make -C docs html + run: pipx run poetry run make -C docs html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4a6ad7c..f68e8d6d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Changed * Relax ``async-timeout`` version to support different installations. Merged #1009. * ``unpair`` function of ``BleakClient`` in WinRT backend can be called without being connected to remove stored device information * Use relative imports internally. Merged #1007. +* ``BleakScanner`` and ``BleakClient`` are now concrete classes. Fixes #582. +* Deprecated ``BleakScanner.register_detection_callback()``. +* Deprecated ``BleakScanner.set_scanning_filter()``. +* Deprecated ``BleakClient.set_disconnected_callback()``. +* Deprecated ``BleakClient.get_services()``. Fixed ----- diff --git a/bleak/__init__.py b/bleak/__init__.py index 61d4b8a5..de2038f7 100644 --- a/bleak/__init__.py +++ b/bleak/__init__.py @@ -2,20 +2,42 @@ """Top-level package for bleak.""" +from __future__ import annotations + __author__ = """Henrik Blidh""" __email__ = "henrik.blidh@gmail.com" import asyncio import logging import os -import platform import sys +import uuid +from typing import TYPE_CHECKING, Callable, List, Optional, Union from warnings import warn +import async_timeout + +if sys.version_info[:2] < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + from .__version__ import __version__ # noqa: F401 -from .exc import BleakError +from .backends.characteristic import BleakGATTCharacteristic +from .backends.client import get_platform_client_backend_type +from .backends.device import BLEDevice +from .backends.scanner import ( + AdvertisementData, + AdvertisementDataCallback, + AdvertisementDataFilter, + get_platform_scanner_backend_type, +) +from .backends.service import BleakGATTServiceCollection + +if TYPE_CHECKING: + from .backends.bluezdbus.scanner import BlueZScannerArgs + from .backends.winrt.client import WinRTClientArgs -_on_rtd = os.environ.get("READTHEDOCS") == "True" _logger = logging.getLogger(__name__) _logger.addHandler(logging.NullHandler()) @@ -27,55 +49,503 @@ _logger.addHandler(handler) _logger.setLevel(logging.DEBUG) -if _on_rtd: - pass -elif os.environ.get("P4A_BOOTSTRAP") is not None: - from .backends.p4android.client import ( # noqa: F401 - BleakClientP4Android as BleakClient, - ) - from .backends.p4android.scanner import ( # noqa: F401 - BleakScannerP4Android as BleakScanner, - ) -elif platform.system() == "Linux": - from .backends.bluezdbus.client import ( # noqa: F401 - BleakClientBlueZDBus as BleakClient, - ) - from .backends.bluezdbus.scanner import ( # noqa: F401 - BleakScannerBlueZDBus as BleakScanner, - ) -elif platform.system() == "Darwin": - try: - from CoreBluetooth import CBPeripheral # noqa: F401 - except Exception as ex: - raise BleakError("Bleak requires the CoreBluetooth Framework") from ex - - from .backends.corebluetooth.client import ( # noqa: F401 - BleakClientCoreBluetooth as BleakClient, - ) - from .backends.corebluetooth.scanner import ( # noqa: F401 - BleakScannerCoreBluetooth as BleakScanner, - ) -elif platform.system() == "Windows": - # Requires Windows 10 Creators update at least, i.e. Window 10.0.16299 - _vtup = platform.win32_ver()[1].split(".") - if int(_vtup[0]) != 10: - raise BleakError( - "Only Windows 10 is supported. Detected was {0}".format( - platform.win32_ver() - ) +class BleakScanner: + """ + Interface for Bleak Bluetooth LE Scanners. + + Args: + detection_callback: + Optional function that will be called each time a device is + discovered or advertising data has changed. + service_uuids: + Optional list of service UUIDs to filter on. Only advertisements + containing this advertising data will be received. Required on + macOS >= 12.0, < 12.3 (unless you create an app with ``py2app``). + scanning_mode: + Set to ``"passive"`` to avoid the ``"active"`` scanning mode. + Passive scanning is not supported on macOS! Will raise + :class:`BleakError` if set to ``"passive"`` on macOS. + bluez: + Dictionary of arguments specific to the BlueZ backend. + **kwargs: + Additional args for backwards compatibility. + """ + + def __init__( + self, + detection_callback: Optional[AdvertisementDataCallback] = None, + service_uuids: Optional[List[str]] = None, + scanning_mode: Literal["active", "passive"] = "active", + *, + bluez: BlueZScannerArgs = {}, + **kwargs, + ): + PlatformBleakScanner = get_platform_scanner_backend_type() + self._backend = PlatformBleakScanner( + detection_callback, service_uuids, scanning_mode, bluez=bluez, **kwargs ) - if (int(_vtup[1]) == 0) and (int(_vtup[2]) < 16299): - raise BleakError( - "Requires at least Windows 10 version 0.16299 (Fall Creators Update)." + async def __aenter__(self): + await self._backend.start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._backend.stop() + + def register_detection_callback( + self, callback: Optional[AdvertisementDataCallback] + ) -> None: + """ + Register a callback that is called when a device is discovered or has a property changed. + + .. deprecated:: 0.17.0 + This method will be removed in a future version of Bleak. Pass + the callback directly to the :class:`BleakScanner` constructor instead. + + Args: + callback: A function, coroutine or ``None``. + + + """ + warn( + "This method will be removed in a future version of Bleak. Use the detection_callback of the BleakScanner constructor instead.", + FutureWarning, + stacklevel=2, ) + self._backend.register_detection_callback(callback) - from .backends.winrt.client import BleakClientWinRT as BleakClient # noqa: F401 - from .backends.winrt.scanner import BleakScannerWinRT as BleakScanner # noqa: F401 + async def start(self): + """Start scanning for devices""" + await self._backend.start() -else: - raise BleakError(f"Unsupported platform: {platform.system()}") + async def stop(self): + """Stop scanning for devices""" + await self._backend.stop() + + def set_scanning_filter(self, **kwargs): + """ + Set scanning filter for the BleakScanner. + + .. deprecated:: 0.17.0 + This method will be removed in a future version of Bleak. Pass + arguments directly to the :class:`BleakScanner` constructor instead. + + Args: + **kwargs: The filter details. + + """ + warn( + "This method will be removed in a future version of Bleak. Use BleakScanner constructor args instead.", + FutureWarning, + stacklevel=2, + ) + self._backend.set_scanning_filter(**kwargs) + + @classmethod + async def discover(cls, timeout=5.0, **kwargs) -> List[BLEDevice]: + """ + Scan continuously for ``timeout`` seconds and return discovered devices. + + Args: + timeout: + Time, in seconds, to scan for. + **kwargs: + Additional arguments will be passed to the :class:`BleakScanner` + constructor. + + Returns: + + """ + async with cls(**kwargs) as scanner: + await asyncio.sleep(timeout) + devices = scanner.discovered_devices + return devices + + @property + def discovered_devices(self) -> List[BLEDevice]: + """Gets the devices registered by the BleakScanner. + + Returns: + A list of the devices that the scanner has discovered during the scanning. + """ + return self._backend.discovered_devices + + async def get_discovered_devices(self) -> List[BLEDevice]: + """Gets the devices registered by the BleakScanner. + + .. deprecated:: 0.11.0 + This method will be removed in a future version of Bleak. Use the + :attr:`.discovered_devices` property instead. + + Returns: + A list of the devices that the scanner has discovered during the scanning. + + """ + warn( + "This method will be removed in a future version of Bleak. Use the `discovered_devices` property instead.", + FutureWarning, + stacklevel=2, + ) + return self.discovered_devices + + @classmethod + async def find_device_by_address( + cls, device_identifier: str, timeout: float = 10.0, **kwargs + ) -> Optional[BLEDevice]: + """A convenience method for obtaining a ``BLEDevice`` object specified by Bluetooth address or (macOS) UUID address. + + Args: + device_identifier (str): The Bluetooth/UUID address of the Bluetooth peripheral sought. + timeout (float): Optional timeout to wait for detection of specified peripheral before giving up. Defaults to 10.0 seconds. + + Keyword Args: + adapter (str): Bluetooth adapter to use for discovery. + + Returns: + The ``BLEDevice`` sought or ``None`` if not detected. + + """ + device_identifier = device_identifier.lower() + return await cls.find_device_by_filter( + lambda d, ad: d.address.lower() == device_identifier, + timeout=timeout, + **kwargs, + ) + + @classmethod + async def find_device_by_filter( + cls, filter_func: AdvertisementDataFilter, timeout: float = 10.0, **kwargs + ) -> Optional[BLEDevice]: + """ + A convenience method for obtaining a ``BLEDevice`` object specified by + a filter function. + + Args: + filter_func: + A function that is called for every BLEDevice found. It should + return ``True`` only for the wanted device. + timeout: + Optional timeout to wait for detection of specified peripheral + before giving up. Defaults to 10.0 seconds. + **kwargs: + Additional arguments to be passed to the :class:`BleakScanner` + constructor. + + Returns: + The :class:`BLEDevice` sought or ``None`` if not detected before + the timeout. + + """ + found_device_queue: asyncio.Queue[BLEDevice] = asyncio.Queue() + + def apply_filter(d: BLEDevice, ad: AdvertisementData): + if filter_func(d, ad): + found_device_queue.put_nowait(d) + + async with cls(detection_callback=apply_filter, **kwargs): + try: + async with async_timeout.timeout(timeout): + return await found_device_queue.get() + except asyncio.TimeoutError: + return None + + +class BleakClient: + """The Client Interface for Bleak Backend implementations to implement. + + Args: + device_or_address: + A :class:`BLEDevice` received from a :class:`BleakScanner` or a + Bluetooth address (device UUID on macOS). + disconnected_callback: + Callback that will be scheduled in the event loop when the client is + disconnected. The callable must take one argument, which will be + this client object. + timeout: + Timeout in seconds passed to the implicit ``discover`` call when + ``device_or_address`` is not a :class:`BLEDevice`. Defaults to 10.0. + winrt: + Dictionary of WinRT/Windows platform-specific options. + **kwargs: + Additional keyword arguments for backwards compatibility. + + .. warning:: Although example code frequently initializes :class:`BleakClient` + with a Bluetooth address for simplicity, it is not recommended to do so + for more complex use cases. There are several known issues with providing + a Bluetooth address as the ``device_or_address`` argument. + + 1. macOS does not provide access to the Bluetooth address for privacy/ + security reasons. Instead it creates a UUID for each Bluetooth + device which is used in place of the address on this platform. + 2. Providing an address or UUID instead of a :class:`BLEDevice` causes + the :meth:`connect` method to implicitly call :meth:`BleakScanner.discover`. + This is known to cause problems when trying to connect to multiple + devices at the same time. + """ + + def __init__( + self, + device_or_address: Union[BLEDevice, str], + disconnected_callback: Optional[Callable[[BleakClient], None]] = None, + *, + timeout: float = 10.0, + winrt: WinRTClientArgs = {}, + **kwargs, + ): + PlatformBleakClient = get_platform_client_backend_type() + self._backend = PlatformBleakClient( + device_or_address, + disconnected_callback=disconnected_callback, + timeout=timeout, + winrt=winrt, + **kwargs, + ) + + # device info + + @property + def address(self) -> str: + """ + Gets the Bluetooth address of this device (UUID on macOS). + """ + return self._backend.address + + def __str__(self): + return f"{self.__class__.__name__}, {self.address}" + + def __repr__(self): + return f"<{self.__class__.__name__}, {self.address}, {type(self._backend)}>" + + # Async Context managers + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.disconnect() + + # Connectivity methods + + def set_disconnected_callback( + self, callback: Optional[Callable[[BleakClient], None]], **kwargs + ) -> None: + """Set the disconnect callback. + + .. deprecated:: 0.17.0 + This method will be removed in a future version of Bleak. + Pass the callback to the :class:`BleakClient` constructor instead. + + Args: + callback: callback to be called on disconnection. + + """ + warn( + "This method will be removed future version, pass the callback to the BleakClient constructor instead.", + FutureWarning, + stacklevel=2, + ) + self._backend.set_disconnected_callback(callback, **kwargs) + + async def connect(self, **kwargs) -> bool: + """Connect to the specified GATT server. + + Args: + **kwargs: For backwards compatibility - should not be used. + + Returns: + Always returns ``True`` for backwards compatibility. + + """ + return await self._backend.connect(**kwargs) + + async def disconnect(self) -> bool: + """Disconnect from the specified GATT server. + + Returns: + Always returns ``True`` for backwards compatibility. + + """ + return await self._backend.disconnect() + + async def pair(self, *args, **kwargs) -> bool: + """ + Pair with the peripheral. + + Returns: + Always returns ``True`` for backwards compatibility. + + """ + return await self._backend.pair(*args, **kwargs) + + async def unpair(self) -> bool: + """ + Unpair with the peripheral. + + Returns: + Always returns ``True`` for backwards compatibility. + """ + return await self._backend.unpair() + + @property + def is_connected(self) -> bool: + """ + Check connection status between this client and the server. + + Returns: + Boolean representing connection status. + + """ + return self._backend.is_connected + + # GATT services methods + + async def get_services(self, **kwargs) -> BleakGATTServiceCollection: + """Get all services registered for this GATT server. + + .. deprecated:: 0.17.0 + This method will be removed in a future version of Bleak. + Use the :attr:`services` property instead. + + Returns: + A :class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree. + + """ + warn( + "This method will be removed future version, use the services property instead.", + FutureWarning, + stacklevel=2, + ) + return await self._backend.get_services(**kwargs) + + @property + def services(self) -> BleakGATTServiceCollection: + """ + Gets the collection of GATT services available on the device. + + The returned value is only valid as long as the device is connected. + """ + return self._backend.services + + # I/O methods + + 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: + The characteristic to read from, specified by either integer + handle, UUID or directly by the BleakGATTCharacteristic object + representing it. + + Returns: + The read data. + + """ + return await self._backend.read_gatt_char(char_specifier, **kwargs) + + async def write_gatt_char( + self, + char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], + data: Union[bytes, bytearray, memoryview], + response: bool = False, + ) -> None: + """ + Perform a write operation on the specified GATT characteristic. + + Args: + char_specifier: + The characteristic to write to, specified by either integer + handle, UUID or directly by the BleakGATTCharacteristic object + representing it. + data: + The data to send. + response: + If write-with-response operation should be done. Defaults to ``False``. + + """ + await self._backend.write_gatt_char(char_specifier, data, response) + + async def start_notify( + self, + char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], + callback: Callable[[int, bytearray], None], + **kwargs, + ) -> None: + """ + Activate notifications/indications on a characteristic. + + Callbacks must accept two inputs. The first will be a integer handle of + the characteristic generating the data and the second will be a ``bytearray``. + + .. code-block:: python + + def callback(sender: int, data: bytearray): + print(f"{sender}: {data}") + client.start_notify(char_uuid, callback) + + Args: + char_specifier: + The characteristic to activate notifications/indications on a + characteristic, specified by either integer handle, + UUID or directly by the BleakGATTCharacteristic object representing it. + callback: + The function to be called on notification. + + """ + await self._backend.start_notify(char_specifier, callback, **kwargs) + + async def stop_notify( + self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID] + ) -> None: + """ + Deactivate notification/indication on a specified characteristic. + + Args: + char_specifier: + The characteristic to deactivate notification/indication on, + specified by either integer handle, UUID or directly by the + BleakGATTCharacteristic object representing it. + + .. tip:: Notifications are stopped automatically on disconnect, so this + method does not need to be called unless notifications need to be + stopped some time before the device disconnects. + """ + await self._backend.stop_notify(char_specifier) + + async def read_gatt_descriptor(self, handle: int, **kwargs) -> bytearray: + """ + Perform read operation on the specified GATT descriptor. + + Args: + handle: The handle of the descriptor to read from. + + Returns: + The read data. + + """ + return await self._backend.read_gatt_descriptor(handle, **kwargs) + + async def write_gatt_descriptor( + self, handle: int, data: Union[bytes, bytearray, memoryview] + ) -> 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. + + """ + await self._backend.write_gatt_descriptor(handle, data) # for backward compatibility diff --git a/bleak/backends/bluezdbus/scanner.py b/bleak/backends/bluezdbus/scanner.py index ae3e24f3..80674436 100644 --- a/bleak/backends/bluezdbus/scanner.py +++ b/bleak/backends/bluezdbus/scanner.py @@ -75,11 +75,11 @@ class BleakScannerBlueZDBus(BaseBleakScanner): def __init__( self, - detection_callback: Optional[AdvertisementDataCallback] = None, - service_uuids: Optional[List[str]] = None, - scanning_mode: Literal["active", "passive"] = "active", + detection_callback: Optional[AdvertisementDataCallback], + service_uuids: Optional[List[str]], + scanning_mode: Literal["active", "passive"], *, - bluez: BlueZScannerArgs = {}, + bluez: BlueZScannerArgs, **kwargs, ): super(BleakScannerBlueZDBus, self).__init__(detection_callback, service_uuids) diff --git a/bleak/backends/client.py b/bleak/backends/client.py index a402891e..d29deff4 100644 --- a/bleak/backends/client.py +++ b/bleak/backends/client.py @@ -7,10 +7,13 @@ """ import abc import asyncio +import os +import platform import uuid -from typing import Callable, Optional, Union +from typing import Callable, Optional, Type, Union from warnings import warn +from ..exc import BleakError from .service import BleakGATTServiceCollection from .characteristic import BleakGATTCharacteristic from .device import BLEDevice @@ -45,25 +48,6 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): self._timeout = kwargs.get("timeout", 10.0) self._disconnected_callback = kwargs.get("disconnected_callback") - def __str__(self): - return "{0}, {1}".format(self.__class__.__name__, self.address) - - def __repr__(self): - return "<{0}, {1}, {2}>".format( - self.__class__.__name__, - self.address, - super(BaseBleakClient, self).__repr__(), - ) - - # Async Context managers - - async def __aenter__(self): - await self.connect() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.disconnect() - # Connectivity methods def set_disconnected_callback( @@ -171,7 +155,7 @@ async def get_services(self, **kwargs) -> BleakGATTServiceCollection: async def read_gatt_char( self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], - **kwargs + **kwargs, ) -> bytearray: """Perform read operation on the specified GATT characteristic. @@ -236,7 +220,7 @@ async def start_notify( self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], callback: Callable[[int, bytearray], None], - **kwargs + **kwargs, ) -> None: """Activate notifications/indications on a characteristic. @@ -271,3 +255,30 @@ async def stop_notify( """ raise NotImplementedError() + + +def get_platform_client_backend_type() -> Type[BaseBleakClient]: + """ + Gets the platform-specific :class:`BaseBleakClient` type. + """ + if os.environ.get("P4A_BOOTSTRAP") is not None: + from bleak.backends.p4android.client import BleakClientP4Android + + return BleakClientP4Android + + if platform.system() == "Linux": + from bleak.backends.bluezdbus.client import BleakClientBlueZDBus + + return BleakClientBlueZDBus + + if platform.system() == "Darwin": + from bleak.backends.corebluetooth.client import BleakClientCoreBluetooth + + return BleakClientCoreBluetooth + + if platform.system() == "Windows": + from bleak.backends.winrt.client import BleakClientWinRT + + return BleakClientWinRT + + raise BleakError(f"Unsupported platform: {platform.system()}") diff --git a/bleak/backends/corebluetooth/scanner.py b/bleak/backends/corebluetooth/scanner.py index 5fd4a765..641f0764 100644 --- a/bleak/backends/corebluetooth/scanner.py +++ b/bleak/backends/corebluetooth/scanner.py @@ -50,9 +50,9 @@ class BleakScannerCoreBluetooth(BaseBleakScanner): def __init__( self, - detection_callback: Optional[AdvertisementDataCallback] = None, - service_uuids: Optional[List[str]] = None, - scanning_mode: Literal["active", "passive"] = "active", + detection_callback: Optional[AdvertisementDataCallback], + service_uuids: Optional[List[str]], + scanning_mode: Literal["active", "passive"], **kwargs ): super(BleakScannerCoreBluetooth, self).__init__( diff --git a/bleak/backends/p4android/scanner.py b/bleak/backends/p4android/scanner.py index 3a02b5c1..77c63275 100644 --- a/bleak/backends/p4android/scanner.py +++ b/bleak/backends/p4android/scanner.py @@ -44,9 +44,9 @@ class BleakScannerP4Android(BaseBleakScanner): def __init__( self, - detection_callback: Optional[AdvertisementDataCallback] = None, - service_uuids: Optional[List[str]] = None, - scanning_mode: Literal["active", "passive"] = "active", + detection_callback: Optional[AdvertisementDataCallback], + service_uuids: Optional[List[str]], + scanning_mode: Literal["active", "passive"], **kwargs, ): super(BleakScannerP4Android, self).__init__(detection_callback, service_uuids) diff --git a/bleak/backends/scanner.py b/bleak/backends/scanner.py index f1a0fc05..d7d2a6bc 100644 --- a/bleak/backends/scanner.py +++ b/bleak/backends/scanner.py @@ -1,11 +1,11 @@ import abc import asyncio import inspect -from typing import Awaitable, Callable, Dict, List, Optional, Tuple -from warnings import warn - -import async_timeout +import os +import platform +from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type +from ..exc import BleakError from .device import BLEDevice @@ -93,32 +93,6 @@ def __init__( [u.lower() for u in service_uuids] if service_uuids is not None else None ) - async def __aenter__(self): - await self.start() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.stop() - - @classmethod - async def discover(cls, timeout=5.0, **kwargs) -> List[BLEDevice]: - """Scan continuously for ``timeout`` seconds and return discovered devices. - - Args: - timeout: Time to scan for. - - Keyword Args: - **kwargs: Implementations might offer additional keyword arguments sent to the constructor of the - BleakScanner class. - - Returns: - - """ - async with cls(**kwargs) as scanner: - await asyncio.sleep(timeout) - devices = scanner.discovered_devices - return devices - def register_detection_callback( self, callback: Optional[AdvertisementDataCallback] ) -> None: @@ -183,74 +157,29 @@ def discovered_devices(self) -> List[BLEDevice]: """ raise NotImplementedError() - async def get_discovered_devices(self) -> List[BLEDevice]: - """Gets the devices registered by the BleakScanner. - - .. deprecated:: 0.11.0 - This method will be removed in a future version of Bleak. Use the - :attr:`.discovered_devices` property instead. - - Returns: - A list of the devices that the scanner has discovered during the scanning. - - """ - warn( - "This method will be removed in a future version of Bleak. Use the `discovered_devices` property instead.", - FutureWarning, - stacklevel=2, - ) - return self.discovered_devices - - @classmethod - async def find_device_by_address( - cls, device_identifier: str, timeout: float = 10.0, **kwargs - ) -> Optional[BLEDevice]: - """A convenience method for obtaining a ``BLEDevice`` object specified by Bluetooth address or (macOS) UUID address. - Args: - device_identifier (str): The Bluetooth/UUID address of the Bluetooth peripheral sought. - timeout (float): Optional timeout to wait for detection of specified peripheral before giving up. Defaults to 10.0 seconds. +def get_platform_scanner_backend_type() -> Type[BaseBleakScanner]: + """ + Gets the platform-specific :class:`BaseBleakScanner` type. + """ + if os.environ.get("P4A_BOOTSTRAP") is not None: + from bleak.backends.p4android.scanner import BleakScannerP4Android - Keyword Args: - adapter (str): Bluetooth adapter to use for discovery. + return BleakScannerP4Android - Returns: - The ``BLEDevice`` sought or ``None`` if not detected. + if platform.system() == "Linux": + from bleak.backends.bluezdbus.scanner import BleakScannerBlueZDBus - """ - device_identifier = device_identifier.lower() - return await cls.find_device_by_filter( - lambda d, ad: d.address.lower() == device_identifier, - timeout=timeout, - **kwargs, - ) + return BleakScannerBlueZDBus - @classmethod - async def find_device_by_filter( - cls, filterfunc: AdvertisementDataFilter, timeout: float = 10.0, **kwargs - ) -> Optional[BLEDevice]: - """A convenience method for obtaining a ``BLEDevice`` object specified by a filter function. + if platform.system() == "Darwin": + from bleak.backends.corebluetooth.scanner import BleakScannerCoreBluetooth - Args: - filterfunc (AdvertisementDataFilter): A function that is called for every BLEDevice found. It should return True only for the wanted device. - timeout (float): Optional timeout to wait for detection of specified peripheral before giving up. Defaults to 10.0 seconds. + return BleakScannerCoreBluetooth - Keyword Args: - adapter (str): Bluetooth adapter to use for discovery. + if platform.system() == "Windows": + from bleak.backends.winrt.scanner import BleakScannerWinRT - Returns: - The ``BLEDevice`` sought or ``None`` if not detected. + return BleakScannerWinRT - """ - found_device_queue = asyncio.Queue() - - def apply_filter(d: BLEDevice, ad: AdvertisementData): - if filterfunc(d, ad): - found_device_queue.put_nowait(d) - - async with cls(detection_callback=apply_filter, **kwargs): - try: - async with async_timeout.timeout(timeout): - return await found_device_queue.get() - except asyncio.TimeoutError: - return None + raise BleakError(f"Unsupported platform: {platform.system()}") diff --git a/bleak/backends/winrt/client.py b/bleak/backends/winrt/client.py index 5edda76c..aa1314b8 100644 --- a/bleak/backends/winrt/client.py +++ b/bleak/backends/winrt/client.py @@ -162,7 +162,7 @@ def __init__( self, address_or_ble_device: Union[BLEDevice, str], *, - winrt: WinRTClientArgs = {}, + winrt: WinRTClientArgs, **kwargs, ): super(BleakClientWinRT, self).__init__(address_or_ble_device, **kwargs) diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 34b3bbbb..1a5f248d 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -74,9 +74,9 @@ class BleakScannerWinRT(BaseBleakScanner): def __init__( self, - detection_callback: Optional[AdvertisementDataCallback] = None, - service_uuids: Optional[List[str]] = None, - scanning_mode: Literal["active", "passive"] = "active", + detection_callback: Optional[AdvertisementDataCallback], + service_uuids: Optional[List[str]], + scanning_mode: Literal["active", "passive"], **kwargs, ): super(BleakScannerWinRT, self).__init__(detection_callback, service_uuids) diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index 1fde94ad..00000000 --- a/docs/api.rst +++ /dev/null @@ -1,102 +0,0 @@ -Interfaces, exceptions and utils -================================ - -Connection Clients ------------------- - -Interface -~~~~~~~~~ - -.. automodule:: bleak.backends.client - :members: - -Windows -~~~~~~~ - -.. automodule:: bleak.backends.winrt.client - :members: - -macOS -~~~~~ - -.. automodule:: bleak.backends.corebluetooth.client - :members: - -Linux Distributions with BlueZ -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: bleak.backends.bluezdbus.client - :members: - -Python-for-Android/Kivy -~~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: bleak.backends.p4android.client - :members: - -Scanning Clients ----------------- - -Interface -~~~~~~~~~ - -.. automodule:: bleak.backends.scanner - :members: - -Windows -~~~~~~~ - -.. automodule:: bleak.backends.winrt.scanner - :members: - -macOS -~~~~~ - -.. automodule:: bleak.backends.corebluetooth.scanner - :members: - -Linux Distributions with BlueZ -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: bleak.backends.bluezdbus.scanner - :members: - -Python-for-Android/Kivy -~~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: bleak.backends.p4android.scanner - :members: - - -Class representing BLE devices ------------------------------- - -Generated by :py:meth:`bleak.discover` and :py:class:`bleak.backends.scanning.BaseBleakScanner`. - -.. automodule:: bleak.backends.device - :members: - -GATT objects ------------- - -.. automodule:: bleak.backends.service - :members: - -.. automodule:: bleak.backends.characteristic - :members: - -.. automodule:: bleak.backends.descriptor - :members: - - -Exceptions ----------- - -.. automodule:: bleak.exc - :members: - -Utilities ---------- - -.. automodule:: bleak.uuids - :members: diff --git a/docs/api/client.rst b/docs/api/client.rst new file mode 100644 index 00000000..59a237e8 --- /dev/null +++ b/docs/api/client.rst @@ -0,0 +1,112 @@ +================= +BleakClient class +================= + +.. autoclass:: bleak.BleakClient + +---------------------------- +Connecting and disconnecting +---------------------------- + +Before doing anything else with a :class:`BleakClient` object, it must be connected. + +:class:`bleak.BleakClient` is a an async context manager, so the recommended +way of connecting is to use it as such:: + + import asyncio + from bleak import BleakClient + + async def main(): + async with BleakClient("XX:XX:XX:XX:XX:XX") as client: + # Read a characteristic, etc. + ... + + # Device will disconnect when block exits. + ... + + # Using asyncio.run() is important to ensure that device disconnects on + # KeyboardInterrupt or other unhandled exception. + asyncio.run(main()) + + +It is also possible to connect and disconnect without a context manager, however +this can leave the device still connected when the program exits: + +.. automethod:: bleak.BleakClient.connect +.. automethod:: bleak.BleakClient.disconnect + +The current connection status can be retrieved with: + +.. autoproperty:: bleak.BleakClient.is_connected + +A callback can be provided to the :class:`BleakClient` constructor via the +``disconnect_callback`` argument to be notified of disconnection events. + + +------------------ +Device information +------------------ + +.. autoproperty:: bleak.BleakClient.address + + +---------------------- +GATT Client Operations +---------------------- + +All Bluetooth Low Energy devices use a common Generic Attribute Profile (GATT) +for interacting with the device after it is connected. Some GATT operations +like discovering the services/characteristic/descriptors and negotiating the +MTU are handled automatically by Bleak and/or the OS Bluetooth stack. + +The primary operations for the Bleak client are reading, writing and subscribing +to characteristics. + +Services +======== + +The available services on a device are automatically enumerated when connecting +to a device. Services describe the devices capabilities. + +.. autoproperty:: bleak.BleakClient.services + + +GATT characteristics +==================== + +Most I/O with a device is done via the characteristics. + +.. automethod:: bleak.BleakClient.read_gatt_char +.. automethod:: bleak.BleakClient.write_gatt_char +.. automethod:: bleak.BleakClient.start_notify +.. automethod:: bleak.BleakClient.stop_notify + + +GATT descriptors +================ + +Descriptors can provide additional information about a characteristic. + +.. automethod:: bleak.BleakClient.read_gatt_descriptor +.. automethod:: bleak.BleakClient.write_gatt_descriptor + + +--------------- +Pairing/bonding +--------------- + +On some devices, some characteristics may require authentication in order to +read or write the characteristic. In this case pairing/bonding the device is +required. + + +.. automethod:: bleak.BleakClient.pair +.. automethod:: bleak.BleakClient.unpair + + +---------- +Deprecated +---------- + +.. automethod:: bleak.BleakClient.set_disconnected_callback +.. automethod:: bleak.BleakClient.get_services diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 00000000..e80bcc8b --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,54 @@ +============= +API reference +============= + + +Contents: + +.. toctree:: + :maxdepth: 2 + + scanner + client + +.. TODO: move everything below to separate pages + +Class representing BLE devices +------------------------------ + +Generated by :py:meth:`bleak.discover` and :py:class:`bleak.backends.scanning.BaseBleakScanner`. + +.. automodule:: bleak.backends.device + :members: + +GATT objects +------------ + +.. automodule:: bleak.backends.service + :members: + +.. automodule:: bleak.backends.characteristic + :members: + +.. automodule:: bleak.backends.descriptor + :members: + + +Exceptions +---------- + +.. automodule:: bleak.exc + :members: + +Utilities +--------- + +.. automodule:: bleak.uuids + :members: + +Deprecated +---------- + +.. module:: bleak + +.. autofunction:: bleak.discover diff --git a/docs/api/scanner.rst b/docs/api/scanner.rst new file mode 100644 index 00000000..3d8a5375 --- /dev/null +++ b/docs/api/scanner.rst @@ -0,0 +1,66 @@ +================== +BleakScanner class +================== + +.. autoclass:: bleak.BleakScanner + + +------------ +Easy methods +------------ + +These methods and handy for simple programs but are not recommended for +more advanced use cases like long running programs, GUIs or connecting to +multiple devices. + +.. automethod:: bleak.BleakScanner.discover +.. autoproperty:: bleak.BleakScanner.discovered_devices +.. automethod:: bleak.BleakScanner.find_device_by_address +.. automethod:: bleak.BleakScanner.find_device_by_filter + + +--------------------- +Starting and stopping +--------------------- + +:class:`BleakScanner` is an context manager so the recommended way to start +and stop scanning is to use it in an ``async with`` statement:: + + import asyncio + from bleak import BleakScanner + + def main(): + stop_event = asyncio.Event() + + # TODO: add something that calls stop_event.set() + + def callback(device, advertising_data): + # TODO: do something with incoming data + pass + + async with BleakScanner(callback) as scanner: + ... + # Important! Wait for an event to trigger stop, otherwise scanner + # will stop immediately. + await stop_event.wait() + + # scanner stops when block exits + ... + + asyncio.run(main()) + + +It can also be started and stopped without using the context manager using the +following methods: + +.. automethod:: bleak.BleakScanner.start +.. automethod:: bleak.BleakScanner.stop + + +---------- +Deprecated +---------- + +.. automethod:: bleak.BleakScanner.register_detection_callback +.. automethod:: bleak.BleakScanner.set_scanning_filter +.. automethod:: bleak.BleakScanner.get_discovered_devices diff --git a/docs/backends/android.rst b/docs/backends/android.rst index dce6ef1e..bebb04ef 100644 --- a/docs/backends/android.rst +++ b/docs/backends/android.rst @@ -57,3 +57,19 @@ succeed if the user does not accept permissions. For an example of building an android bluetooth app, see `the example <../../examples/kivy>`_ and its accompanying `README <../../examples/kivy/README>`_. + +API +--- + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.p4android.scanner + :members: + + +Client +~~~~~~ + +.. automodule:: bleak.backends.p4android.client + :members: diff --git a/docs/backends/index.rst b/docs/backends/index.rst index aa94deb9..2afbb68d 100644 --- a/docs/backends/index.rst +++ b/docs/backends/index.rst @@ -1,5 +1,5 @@ -Bleak backends -============== +Backend implementations +======================= Bleak supports the following operating systems: @@ -19,3 +19,21 @@ Contents: linux macos android + +Shared Backend API +------------------ + +.. warning:: The backend APIs are not considered part of the stable API and + may change without notice. + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.scanner + :members: + +Client +~~~~~~ + +.. automodule:: bleak.backends.client + :members: diff --git a/docs/backends/linux.rst b/docs/backends/linux.rst index b9d264de..6e70504b 100644 --- a/docs/backends/linux.rst +++ b/docs/backends/linux.rst @@ -23,8 +23,28 @@ Before that commit, ``Characteristic.WriteValue`` was only "Write with response" `Bluez 5.46 `_ which can be used to "Write without response", but for older versions of Bluez (5.43, 5.44, 5.45), it is not possible to "Write without response". - Resolving services with ``get_services`` ---------------------------------------- -By default, calling ``get_services`` will wait for services to be resolved before returning the ``BleakGATTServiceCollection``. If a previous connection to the device was made, passing the ``dangerous_use_bleak_cache`` argument will return the cached services without waiting for them to be resolved again. This is useful when you know services have not changed, and you want to use the services immediately, but don't want to wait for them to be resolved again. +By default, calling ``get_services`` will wait for services to be resolved +before returning the ``BleakGATTServiceCollection``. If a previous connection +to the device was made, passing the ``dangerous_use_bleak_cache`` argument will +return the cached services without waiting for them to be resolved again. This +is useful when you know services have not changed, and you want to use the +services immediately, but don't want to wait for them to be resolved again. + + +API +--- + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.bluezdbus.scanner + :members: + +Client +~~~~~~ + +.. automodule:: bleak.backends.bluezdbus.client + :members: diff --git a/docs/backends/macos.rst b/docs/backends/macos.rst index 6044c35a..f4af7a01 100644 --- a/docs/backends/macos.rst +++ b/docs/backends/macos.rst @@ -29,3 +29,18 @@ the scan and thus cached the device as ``243E23AE-4A99-406C-B317-18F1BD7B4CBE``. There is also no pairing functionality implemented in macOS right now, since it does not seem to be any explicit pairing methods in the COre Bluetooth. + +API +--- + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.corebluetooth.scanner + :members: + +Client +~~~~~~ + +.. automodule:: bleak.backends.corebluetooth.client + :members: diff --git a/docs/backends/windows.rst b/docs/backends/windows.rst index fabfb96a..5f3c628e 100644 --- a/docs/backends/windows.rst +++ b/docs/backends/windows.rst @@ -16,3 +16,17 @@ Client - The constructor keyword ``address_type`` which can have the values ``"public"`` or ``"random"``. This value makes sure that the connection is made in a fashion that suits the peripheral. +API +--- + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.winrt.scanner + :members: + +Client +~~~~~~ + +.. automodule:: bleak.backends.winrt.client + :members: diff --git a/docs/index.rst b/docs/index.rst index d6c96b2d..fbfbc28d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,8 +51,8 @@ Contents: installation scanning usage + api/index backends/index - api troubleshooting contributing authors diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 0486e239..ceac2a66 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -103,7 +103,7 @@ Python:: devices: Sequence[BLEDevice] = scanner.discover(timeout=5.0) for d in devices: async with BleakClient(d) as client: - print(await client.get_services()) + print(client.services) asyncio.run(find_all_devices_services()) diff --git a/examples/connect_by_bledevice.py b/examples/connect_by_bledevice.py index 361ad6c2..565ac3a1 100644 --- a/examples/connect_by_bledevice.py +++ b/examples/connect_by_bledevice.py @@ -22,9 +22,8 @@ async def main(ble_address: str): if not device: raise BleakError(f"A device with address {ble_address} could not be found.") async with BleakClient(device) as client: - svcs = await client.get_services() print("Services:") - for service in svcs: + for service in client.services: print(service) diff --git a/examples/get_services.py b/examples/get_services.py index 9aa194df..575dae89 100644 --- a/examples/get_services.py +++ b/examples/get_services.py @@ -23,9 +23,8 @@ async def main(address: str): async with BleakClient(address) as client: - svcs = await client.get_services() print("Services:") - for service in svcs: + for service in client.services: print(service) diff --git a/examples/kivy/main.py b/examples/kivy/main.py index efeefe40..fa18f5d2 100644 --- a/examples/kivy/main.py +++ b/examples/kivy/main.py @@ -58,8 +58,7 @@ async def example(self): self.line(f"Connecting to {device.name} ...") try: async with bleak.BleakClient(device) as client: - services = await client.get_services() - for service in services.services.values(): + for service in client.services: self.line(f" service {service.uuid}") for characteristic in service.characteristics: self.line( diff --git a/tests/test_imports.py b/tests/test_imports.py deleted file mode 100644 index 5163a9a4..00000000 --- a/tests/test_imports.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Tests for `bleak` package.""" - -import os -import platform - - -_IS_CI = os.environ.get("CI", "false").lower() == "true" - - -def test_import(): - """Test by importing the client and assert correct client by OS.""" - if platform.system() == "Linux": - from bleak import BleakClient - - assert BleakClient.__name__ == "BleakClientBlueZDBus" - elif platform.system() == "Windows": - from bleak import BleakClient - - assert BleakClient.__name__ == "BleakClientWinRT" - elif platform.system() == "Darwin": - from bleak import BleakClient - - assert BleakClient.__name__ == "BleakClientCoreBluetooth" diff --git a/tests/test_platform_detection.py b/tests/test_platform_detection.py new file mode 100644 index 00000000..9413b954 --- /dev/null +++ b/tests/test_platform_detection.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Tests for `bleak` package.""" + +import platform + +from bleak.backends.client import get_platform_client_backend_type +from bleak.backends.scanner import get_platform_scanner_backend_type + + +def test_platform_detection(): + """Test by importing the client and assert correct client by OS.""" + + client_backend_type = get_platform_client_backend_type() + scanner_backend_type = get_platform_scanner_backend_type() + + if platform.system() == "Linux": + assert client_backend_type.__name__ == "BleakClientBlueZDBus" + assert scanner_backend_type.__name__ == "BleakScannerBlueZDBus" + elif platform.system() == "Windows": + assert client_backend_type.__name__ == "BleakClientWinRT" + assert scanner_backend_type.__name__ == "BleakScannerWinRT" + elif platform.system() == "Darwin": + assert client_backend_type.__name__ == "BleakClientCoreBluetooth" + assert scanner_backend_type.__name__ == "BleakScannerCoreBluetooth"