diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 34d2c0d7..e0a4c7a6 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -20,3 +20,13 @@ reproduced with other BLE peripherals as well. Paste the command(s) you ran and the output. If there was a crash, please include the traceback here as well. ``` + +### Logs + +Include any relevant logs here. + +Logs are essential to understand what is going on behind the scenes. + +See https://bleak.readthedocs.io/en/latest/troubleshooting.html for information on how to collect debug logs. + +If you receive an OS error (`WinError`, `BleakDBusError`, `NSError`, etc.), Wireshark logs of Bluetooth packets are required to understand the issue! diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 8741fa9d..62e706fc 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11.0-rc.2'] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index d00892a9..00000000 --- a/.pyup.yml +++ /dev/null @@ -1,8 +0,0 @@ -update: all -pin: True -branch: develop -schedule: "every day" -search: True -assignees: - - hbldh -branch_prefix: pyup/ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1945a3b2..9fc297ea 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,36 @@ and this project adheres to `Semantic Versioning List[BLEDevice]: + ... + + @overload + @classmethod + async def discover( + cls, timeout: float = 5.0, *, return_adv: Literal[True], **kwargs + ) -> Dict[str, Tuple[BLEDevice, AdvertisementData]]: + ... + @classmethod - async def discover(cls, timeout=5.0, **kwargs) -> List[BLEDevice]: + async def discover(cls, timeout=5.0, *, return_adv=False, **kwargs): """ Scan continuously for ``timeout`` seconds and return discovered devices. Args: timeout: Time, in seconds, to scan for. + return_adv: + If ``True``, the return value will include advertising data. **kwargs: Additional arguments will be passed to the :class:`BleakScanner` constructor. Returns: + The value of :attr:`discovered_devices_and_advertisement_data` if + ``return_adv`` is ``True``, otherwise the value of :attr:`discovered_devices`. + .. versionchanged:: 0.19.0 + Added ``return_adv`` parameter. """ async with cls(**kwargs) as scanner: await asyncio.sleep(timeout) - devices = scanner.discovered_devices - return devices + + if return_adv: + return scanner.discovered_devices_and_advertisement_data + + return scanner.discovered_devices @property def discovered_devices(self) -> List[BLEDevice]: - """Gets the devices registered by the BleakScanner. + """ + Gets list of the devices that the scanner has discovered during the scanning. - Returns: - A list of the devices that the scanner has discovered during the scanning. + If you also need advertisement data, use :attr:`discovered_devices_and_advertisement_data` instead. + """ + return [d for d, _ in self._backend.seen_devices.values()] + + @property + def discovered_devices_and_advertisement_data( + self, + ) -> Dict[str, Tuple[BLEDevice, AdvertisementData]]: + """ + Gets a map of device address to tuples of devices and the most recently + received advertisement data for that device. + + The address keys are useful to compare the discovered devices to a set + of known devices. If you don't need to do that, consider using + ``discovered_devices_and_advertisement_data.values()`` to just get the + values instead. + + .. versionadded:: 0.19.0 """ - return self._backend.discovered_devices + return self._backend.seen_devices async def get_discovered_devices(self) -> List[BLEDevice]: """Gets the devices registered by the BleakScanner. @@ -204,7 +269,7 @@ async def get_discovered_devices(self) -> List[BLEDevice]: 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. + """Obtain a ``BLEDevice`` for a BLE server specified by Bluetooth address or (macOS) UUID address. Args: device_identifier (str): The Bluetooth/UUID address of the Bluetooth peripheral sought. @@ -228,9 +293,10 @@ async def find_device_by_address( 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. + """Obtain a ``BLEDevice`` for a BLE server that matches a given filter function. + + This can be used to find a BLE server by other identifying information than its address, + for example its name. Args: filterfunc: @@ -263,7 +329,13 @@ def apply_filter(d: BLEDevice, ad: AdvertisementData): class BleakClient: - """The Client Interface for Bleak Backend implementations to implement. + """The Client interface for connecting to a specific BLE GATT server and communicating with it. + + A BleakClient can be used as an asynchronous context manager in which case it automatically + connects and disconnects. + + How many BLE connections can be active simultaneously, and whether connections can be active while + scanning depends on the Bluetooth adapter hardware. Args: address_or_ble_device: @@ -296,6 +368,13 @@ class BleakClient: 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. + + .. versionchanged:: 0.15.0 + ``disconnected_callback`` is no longer keyword-only. Added ``winrt`` parameter. + + .. versionchanged:: 0.18.0 + No longer is alias for backend type and no longer inherits from :class:`BaseBleakClient`. + Added ``backend`` parameter. """ def __init__( @@ -402,7 +481,12 @@ async def disconnect(self) -> bool: async def pair(self, *args, **kwargs) -> bool: """ - Pair with the peripheral. + Pair with the specified GATT server. + + This method is not available on macOS. Instead of manually initiating + paring, the user will be prompted to pair the device the first time + that a characteristic that requires authentication is read or written. + This method may have backend-specific additional keyword arguments. Returns: Always returns ``True`` for backwards compatibility. @@ -412,7 +496,12 @@ async def pair(self, *args, **kwargs) -> bool: async def unpair(self) -> bool: """ - Unpair with the peripheral. + Unpair from the specified GATT server. + + Unpairing will also disconnect the device. + + This method is only available on Windows and Linux and will raise an + exception on other platforms. Returns: Always returns ``True`` for backwards compatibility. @@ -422,7 +511,7 @@ async def unpair(self) -> bool: @property def is_connected(self) -> bool: """ - Check connection status between this client and the server. + Check connection status between this client and the GATT server. Returns: Boolean representing connection status. @@ -533,6 +622,10 @@ def callback(sender: int, data: bytearray): The function to be called on notification. Can be regular function or async function. + + .. versionchanged:: 0.18.0 + The first argument of the callback is now a :class:`BleakGATTCharacteristic` + instead of an ``int``. """ if not self.is_connected: raise BleakError("Not connected") @@ -603,7 +696,7 @@ async def write_gatt_descriptor( # for backward compatibility -def discover(): +def discover(*args, **kwargs): """ .. deprecated:: 0.17.0 This method will be removed in a future version of Bleak. @@ -614,7 +707,7 @@ def discover(): FutureWarning, stacklevel=2, ) - return BleakScanner.discover() + return BleakScanner.discover(*args, **kwargs) def cli(): diff --git a/bleak/__version__.py b/bleak/__version__.py deleted file mode 100644 index 1402e033..00000000 --- a/bleak/__version__.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- - -__version__ = "0.18.1" diff --git a/bleak/assigned_numbers.py b/bleak/assigned_numbers.py index 81c5ff4f..0fe2ce85 100644 --- a/bleak/assigned_numbers.py +++ b/bleak/assigned_numbers.py @@ -16,6 +16,8 @@ class AdvertisementDataType(IntEnum): Generic Access Profile advertisement data types. `Source `. + + .. versionadded:: 0.15.0 """ FLAGS = 0x01 diff --git a/bleak/backends/bluezdbus/client.py b/bleak/backends/bluezdbus/client.py index 3d40779b..ff374599 100644 --- a/bleak/backends/bluezdbus/client.py +++ b/bleak/backends/bluezdbus/client.py @@ -11,12 +11,12 @@ import async_timeout from dbus_fast.aio import MessageBus -from dbus_fast.constants import BusType, ErrorType +from dbus_fast.constants import BusType, ErrorType, MessageType from dbus_fast.message import Message from dbus_fast.signature import Variant from ... import BleakScanner -from ...exc import BleakDBusError, BleakError +from ...exc import BleakDBusError, BleakError, BleakDeviceNotFoundError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback from ..device import BLEDevice @@ -116,8 +116,8 @@ async def connect(self, dangerous_use_bleak_cache: bool = False, **kwargs) -> bo self._device_info = device.details.get("props") self._device_path = device.details["path"] else: - raise BleakError( - "Device with address {0} was not found.".format(self.address) + raise BleakDeviceNotFoundError( + self.address, f"Device with address {self.address} was not found." ) manager = await get_global_bluez_manager() @@ -167,6 +167,7 @@ def on_value_changed(char_path: str, value: bytes) -> None: # For additional details see https://github.com/bluez/bluez/issues/89 # if not manager.is_connected(self._device_path): + logger.debug("Connecting to BlueZ path %s", self._device_path) async with async_timeout.timeout(timeout): reply = await self._bus.call( Message( @@ -176,6 +177,16 @@ def on_value_changed(char_path: str, value: bytes) -> None: member="Connect", ) ) + + if ( + reply.message_type == MessageType.ERROR + and reply.error_name == ErrorType.UNKNOWN_OBJECT.value + ): + raise BleakDeviceNotFoundError( + self.address, + f"Device with address {self.address} was not found. It may have been removed from BlueZ when scanning stopped.", + ) + assert_reply(reply) self._is_connected = True @@ -416,10 +427,46 @@ async def unpair(self) -> bool: Boolean regarding success of unpairing. """ - warnings.warn( - "Unpairing is seemingly unavailable in the BlueZ DBus API at the moment." + adapter_path = await self._get_adapter_path() + device_path = await self._get_device_path() + manager = await get_global_bluez_manager() + + logger.debug( + "Removing BlueZ device path %s from adapter path %s", + device_path, + adapter_path, ) - return False + + # If this client object wants to connect again, BlueZ needs the device + # to follow Discovery process again - so reset the local connection + # state. + # + # (This is true even if the request to RemoveDevice fails, + # so clear it before.) + self._device_path = None + self._device_info = None + self._is_connected = False + + try: + reply = await manager._bus.call( + Message( + destination=defs.BLUEZ_SERVICE, + path=adapter_path, + interface=defs.ADAPTER_INTERFACE, + member="RemoveDevice", + signature="o", + body=[device_path], + ) + ) + assert_reply(reply) + except BleakDBusError as e: + if e.dbus_error == "org.bluez.Error.DoesNotExist": + raise BleakDeviceNotFoundError( + self.address, f"Device with address {self.address} was not found." + ) from e + raise + + return True @property def is_connected(self) -> bool: @@ -476,6 +523,37 @@ async def _acquire_mtu(self) -> None: os.close(reply.unix_fds[0]) self._mtu_size = reply.body[1] + async def _get_adapter_path(self) -> str: + """Private coroutine to return the BlueZ path to the adapter this client is assigned to. + + Can be called even if no connection has been established yet. + """ + if self._device_info: + # If we have a BlueZ DBus object with _device_info, use what it tell us + return self._device_info["Adapter"] + if self._adapter: + # If the adapter name was set in the constructor, convert to a BlueZ path + return f"/org/bluez/{self._adapter}" + + # Fall back to the system's default Bluetooth adapter + manager = await get_global_bluez_manager() + return manager.get_default_adapter() + + async def _get_device_path(self) -> str: + """Private coroutine to return the BlueZ path to the device address this client is assigned to. + + Unlike the _device_path property, this function can be called even if discovery process has not + started and/or connection has not been established yet. + """ + if self._device_path: + # If we have a BlueZ DBus object, return its device path + return self._device_path + + # Otherwise, build a new path using the adapter path and the BLE address + adapter_path = await self._get_adapter_path() + bluez_address = self.address.upper().replace(":", "_") + return f"{adapter_path}/dev_{bluez_address}" + @property def mtu_size(self) -> int: """Get ATT MTU size for active connection""" diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 709db795..936aa1d6 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -16,13 +16,15 @@ Dict, Iterable, List, + MutableMapping, NamedTuple, Optional, Set, cast, ) +from weakref import WeakKeyDictionary -from dbus_fast import BusType, Message, MessageType, Variant +from dbus_fast import BusType, Message, MessageType, Variant, unpack_variants from dbus_fast.aio.message_bus import MessageBus from ...exc import BleakError @@ -34,7 +36,7 @@ from .descriptor import BleakGATTDescriptorBlueZDBus from .service import BleakGATTServiceBlueZDBus from .signals import MatchRules, add_match -from .utils import assert_reply, unpack_variants +from .utils import assert_reply logger = logging.getLogger(__name__) @@ -366,6 +368,14 @@ async def active_scan( assert_reply(reply) async def stop() -> None: + # need to remove callbacks first, otherwise we get TxPower + # and RSSI properties removed during stop which causes + # incorrect advertisement data callbacks + self._advertisement_callbacks.remove(callback_and_state) + self._device_removed_callbacks.remove( + device_removed_callback_and_state + ) + async with self._bus_lock: reply = await self._bus.call( Message( @@ -390,11 +400,6 @@ async def stop() -> None: ) assert_reply(reply) - self._advertisement_callbacks.remove(callback_and_state) - self._device_removed_callbacks.remove( - device_removed_callback_and_state - ) - return stop except BaseException: # if starting scanning failed, don't leak the callbacks @@ -471,6 +476,14 @@ async def passive_scan( self._bus.export(monitor_path, monitor) async def stop(): + # need to remove callbacks first, otherwise we get TxPower + # and RSSI properties removed during stop which causes + # incorrect advertisement data callbacks + self._advertisement_callbacks.remove(callback_and_state) + self._device_removed_callbacks.remove( + device_removed_callback_and_state + ) + async with self._bus_lock: self._bus.unexport(monitor_path, monitor) @@ -486,11 +499,6 @@ async def stop(): ) assert_reply(reply) - self._advertisement_callbacks.remove(callback_and_state) - self._device_removed_callbacks.remove( - device_removed_callback_and_state - ) - return stop except BaseException: @@ -836,22 +844,26 @@ def _run_advertisement_callbacks( """ for (callback, adapter_path) in self._advertisement_callbacks: # filter messages from other adapters - if not device_path.startswith(adapter_path): + if adapter_path != device["Adapter"]: continue - # TODO: this should be deep copy, not shallow callback(device_path, device.copy()) +_global_instances: MutableMapping[Any, BlueZManager] = WeakKeyDictionary() + + async def get_global_bluez_manager() -> BlueZManager: """ - Gets the initialized global BlueZ manager instance. + Gets an existing initialized global BlueZ manager instance associated with the current event loop, + or initializes a new instance. """ - if not hasattr(get_global_bluez_manager, "instance"): - setattr(get_global_bluez_manager, "instance", BlueZManager()) - - instance: BlueZManager = getattr(get_global_bluez_manager, "instance") + loop = asyncio.get_running_loop() + try: + instance = _global_instances[loop] + except KeyError: + instance = _global_instances[loop] = BlueZManager() await instance.async_init() diff --git a/bleak/backends/bluezdbus/scanner.py b/bleak/backends/bluezdbus/scanner.py index 80674436..d9cce225 100644 --- a/bleak/backends/bluezdbus/scanner.py +++ b/bleak/backends/bluezdbus/scanner.py @@ -11,23 +11,67 @@ from typing import Literal, TypedDict from ...exc import BleakError -from ..device import BLEDevice from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner from .advertisement_monitor import OrPatternLike from .defs import Device1 from .manager import get_global_bluez_manager +from .utils import bdaddr_from_device_path logger = logging.getLogger(__name__) class BlueZDiscoveryFilters(TypedDict, total=False): + """ + Dictionary of arguments for the ``org.bluez.Adapter1.SetDiscoveryFilter`` + D-Bus method. + + https://github.com/bluez/bluez/blob/master/doc/adapter-api.txt + """ + UUIDs: List[str] + """ + Filter by service UUIDs, empty means match _any_ UUID. + + Normally, the ``service_uuids`` argument of :class:`bleak.BleakScanner` + is used instead. + """ RSSI: int + """ + RSSI threshold value. + """ Pathloss: int + """ + Pathloss threshold value. + """ Transport: str + """ + Transport parameter determines the type of scan. + + This should not be used since it is required to be set to ``"le"``. + """ DuplicateData: bool + """ + Disables duplicate detection of advertisement data. + + This does not affect the ``Filter Duplicates`` parameter of the ``LE Set Scan Enable`` + HCI command to the Bluetooth adapter! + + Although the default value for BlueZ is ``True``, Bleak sets this to ``False`` by default. + """ Discoverable: bool + """ + Make adapter discoverable while discovering, + if the adapter is already discoverable setting + this filter won't do anything. + """ Pattern: str + """ + Discover devices where the pattern matches + either the prefix of the address or + device name which is convenient way to limited + the number of device objects created during a + discovery. + """ class BlueZScannerArgs(TypedDict, total=False): @@ -89,9 +133,6 @@ def __init__( # kwarg "device" is for backwards compatibility self._adapter: Optional[str] = kwargs.get("adapter", kwargs.get("device")) - # map of d-bus object path to d-bus object properties - self._devices: Dict[str, Device1] = {} - # callback from manager for stopping scanning if it has been started self._stop: Optional[Callable[[], Coroutine]] = None @@ -137,7 +178,7 @@ async def start(self): else: adapter_path = manager.get_default_adapter() - self._devices.clear() + self.seen_devices = {} if self._scanning_mode == "passive": self._stop = await manager.passive_scan( @@ -192,25 +233,6 @@ def set_scanning_filter(self, **kwargs): else: logger.warning("Filter '%s' is not currently supported." % k) - @property - def discovered_devices(self) -> List[BLEDevice]: - discovered_devices = [] - - for path, props in self._devices.items(): - uuids = props.get("UUIDs", []) - manufacturer_data = props.get("ManufacturerData", {}) - discovered_devices.append( - BLEDevice( - props["Address"], - props["Alias"], - {"path": path, "props": props}, - props.get("RSSI", 0), - uuids=uuids, - manufacturer_data=manufacturer_data, - ) - ) - return discovered_devices - # Helper methods def _handle_advertising_data(self, path: str, props: Device1) -> None: @@ -222,11 +244,6 @@ def _handle_advertising_data(self, path: str, props: Device1) -> None: props: The D-Bus object properties of the device. """ - self._devices[path] = props - - if self._callback is None: - return - # Get all the information wanted to pack in the advertisement data _local_name = props.get("Name") _manufacturer_data = { @@ -244,19 +261,21 @@ def _handle_advertising_data(self, path: str, props: Device1) -> None: manufacturer_data=_manufacturer_data, service_data=_service_data, service_uuids=_service_uuids, - platform_data=props, tx_power=tx_power, + rssi=props.get("RSSI", -127), + platform_data=(path, props), ) - device = BLEDevice( + device = self.create_or_update_device( props["Address"], props["Alias"], {"path": path, "props": props}, - props.get("RSSI", 0), - uuids=_service_uuids, - manufacturer_data=_manufacturer_data, + advertisement_data, ) + if self._callback is None: + return + self._callback(device, advertisement_data) def _handle_device_removed(self, device_path: str) -> None: @@ -264,9 +283,10 @@ def _handle_device_removed(self, device_path: str) -> None: Handles a device being removed from BlueZ. """ try: - del self._devices[device_path] + bdaddr = bdaddr_from_device_path(device_path) + del self.seen_devices[bdaddr] except KeyError: - # The device will not have been added to self._devices if no + # The device will not have been added to self.seen_devices if no # advertising data was received, so this is expected to happen # occasionally. pass diff --git a/bleak/backends/bluezdbus/utils.py b/bleak/backends/bluezdbus/utils.py index 5c0393e0..79ec8b16 100644 --- a/bleak/backends/bluezdbus/utils.py +++ b/bleak/backends/bluezdbus/utils.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- import re -from typing import Any, Dict from dbus_fast.constants import MessageType from dbus_fast.message import Message -from dbus_fast.signature import Variant from ...exc import BleakError, BleakDBusError @@ -27,26 +25,21 @@ def validate_address(address): return _address_regex.match(address) is not None -def unpack_variants(dictionary: Dict[str, Variant]) -> Dict[str, Any]: - """Recursively unpacks all ``Variant`` types in a dictionary to their - corresponding Python types. - - ``dbus-next`` doesn't automatically do this, so this needs to be called on - all dictionaries ("a{sv}") returned from D-Bus messages. - """ - unpacked = {} - for k, v in dictionary.items(): - v = v.value if isinstance(v, Variant) else v - if isinstance(v, dict): - v = unpack_variants(v) - elif isinstance(v, list): - v = [x.value if isinstance(x, Variant) else x for x in v] - unpacked[k] = v - return unpacked - - def extract_service_handle_from_path(path): try: return int(path[-4:], 16) except Exception as e: raise BleakError(f"Could not parse service handle from path: {path}") from e + + +def bdaddr_from_device_path(device_path: str) -> str: + """ + Scrape the Bluetooth address from a D-Bus device path. + + Args: + device_path: The D-Bus object path of the device. + + Returns: + A Bluetooth address as a string. + """ + return ":".join(device_path[-17:].split("_")) diff --git a/bleak/backends/characteristic.py b/bleak/backends/characteristic.py index a9ae5ed3..27780eae 100644 --- a/bleak/backends/characteristic.py +++ b/bleak/backends/characteristic.py @@ -85,6 +85,8 @@ def max_write_without_response_size(self) -> int: """ Gets the maximum size in bytes that can be used for the *data* argument of :meth:`BleakClient.write_gatt_char()` when ``response=False``. + + .. versionadded:: 0.16.0 """ return self._max_write_without_response_size diff --git a/bleak/backends/corebluetooth/CentralManagerDelegate.py b/bleak/backends/corebluetooth/CentralManagerDelegate.py index 3669665e..cce9eacb 100644 --- a/bleak/backends/corebluetooth/CentralManagerDelegate.py +++ b/bleak/backends/corebluetooth/CentralManagerDelegate.py @@ -61,8 +61,6 @@ def init(self) -> Optional["CentralManagerDelegate"]: self.event_loop = asyncio.get_running_loop() self._connect_futures: Dict[NSUUID, asyncio.Future] = {} - self.last_rssi: Dict[str, int] = {} - self.callbacks: Dict[ int, Callable[[CBPeripheral, Dict[str, Any], int], None] ] = {} @@ -84,6 +82,9 @@ def init(self) -> Optional["CentralManagerDelegate"]: if self.central_manager.state() == CBManagerStateUnsupported: raise BleakError("BLE is unsupported") + if self.central_manager.state() == CBManagerStateUnauthorized: + raise BleakError("BLE is not authorized - check macOS privacy settings") + if self.central_manager.state() != CBManagerStatePoweredOn: raise BleakError("Bluetooth device is turned off") @@ -105,9 +106,6 @@ def __del__(self): @objc.python_method async def start_scan(self, service_uuids) -> None: - # remove old - self.last_rssi.clear() - service_uuids = ( NSArray.alloc().initWithArray_( list(map(CBUUID.UUIDWithString_, service_uuids)) @@ -251,8 +249,6 @@ def did_discover_peripheral( uuid_string = peripheral.identifier().UUIDString() - self.last_rssi[uuid_string] = RSSI - for callback in self.callbacks.values(): if callback: callback(peripheral, advertisementData, RSSI) diff --git a/bleak/backends/corebluetooth/client.py b/bleak/backends/corebluetooth/client.py index 9b066acb..fb91b93c 100644 --- a/bleak/backends/corebluetooth/client.py +++ b/bleak/backends/corebluetooth/client.py @@ -17,7 +17,7 @@ from Foundation import NSArray, NSData from ... import BleakScanner -from ...exc import BleakError +from ...exc import BleakError, BleakDeviceNotFoundError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback from ..device import BLEDevice @@ -52,8 +52,10 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): self._central_manager_delegate: Optional[CentralManagerDelegate] = None if isinstance(address_or_ble_device, BLEDevice): - self._peripheral = address_or_ble_device.details - self._central_manager_delegate = address_or_ble_device.metadata["delegate"] + ( + self._peripheral, + self._central_manager_delegate, + ) = address_or_ble_device.details self._services: Optional[NSArray] = None @@ -77,11 +79,10 @@ async def connect(self, **kwargs) -> bool: ) if device: - self._peripheral = device.details - self._central_manager_delegate = device.metadata["delegate"] + self._peripheral, self._central_manager_delegate = device.details else: - raise BleakError( - "Device with address {} was not found".format(self.address) + raise BleakDeviceNotFoundError( + self.address, f"Device with address {self.address} was not found" ) if self._delegate is None: diff --git a/bleak/backends/corebluetooth/scanner.py b/bleak/backends/corebluetooth/scanner.py index 641f0764..3002dae1 100644 --- a/bleak/backends/corebluetooth/scanner.py +++ b/bleak/backends/corebluetooth/scanner.py @@ -9,10 +9,9 @@ import objc from CoreBluetooth import CBPeripheral -from Foundation import NSUUID, NSArray, NSBundle +from Foundation import NSBundle from ...exc import BleakError -from ..device import BLEDevice from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner from .CentralManagerDelegate import CentralManagerDelegate from .utils import cb_uuid_to_str @@ -62,7 +61,6 @@ def __init__( if scanning_mode == "passive": raise BleakError("macOS does not support passive scanning") - self._identifiers: Optional[Dict[NSUUID, Dict[str, Any]]] = None self._manager = CentralManagerDelegate.alloc().init() self._timeout: float = kwargs.get("timeout", 5.0) if ( @@ -77,14 +75,9 @@ def __init__( ) async def start(self): - self._identifiers = {} + self.seen_devices = {} def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None: - # update identifiers for scanned device - self._identifiers.setdefault(p.identifier(), {}).update(a) - - if not self._callback: - return # Process service data service_data_dict_raw = a.get("kCBAdvDataServiceData", {}) @@ -110,25 +103,25 @@ def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None: tx_power = a.get("kCBAdvDataTxPowerLevel") advertisement_data = AdvertisementData( - local_name=p.name(), + local_name=a.get("kCBAdvDataLocalName"), manufacturer_data=manufacturer_data, service_data=service_data, service_uuids=service_uuids, - platform_data=(p, a, r), tx_power=tx_power, + rssi=r, + platform_data=(p, a, r), ) - device = BLEDevice( + device = self.create_or_update_device( p.identifier().UUIDString(), p.name(), - p, - r, - uuids=service_uuids, - manufacturer_data=manufacturer_data, - service_data=service_data, - delegate=self._manager.central_manager.delegate(), + (p, self._manager.central_manager.delegate()), + advertisement_data, ) + if not self._callback: + return + self._callback(device, advertisement_data) self._manager.callbacks[id(self)] = callback @@ -154,56 +147,6 @@ def set_scanning_filter(self, **kwargs): "Need to evaluate which macOS versions to support first..." ) - @property - def discovered_devices(self) -> List[BLEDevice]: - found = [] - peripherals = self._manager.central_manager.retrievePeripheralsWithIdentifiers_( - NSArray(self._identifiers.keys()), - ) - - for peripheral in peripherals: - address = peripheral.identifier().UUIDString() - name = peripheral.name() or "Unknown" - details = peripheral - rssi = self._manager.last_rssi[address] - - advertisementData = self._identifiers[peripheral.identifier()] - manufacturer_binary_data = advertisementData.get( - "kCBAdvDataManufacturerData" - ) - manufacturer_data = {} - if manufacturer_binary_data: - manufacturer_id = int.from_bytes( - manufacturer_binary_data[0:2], byteorder="little" - ) - manufacturer_value = bytes(manufacturer_binary_data[2:]) - manufacturer_data = {manufacturer_id: manufacturer_value} - - uuids = [ - cb_uuid_to_str(u) - for u in advertisementData.get("kCBAdvDataServiceUUIDs", []) - ] - - service_data = {} - adv_service_data = advertisementData.get("kCBAdvDataServiceData", []) - for u in adv_service_data: - service_data[cb_uuid_to_str(u)] = bytes(adv_service_data[u]) - - found.append( - BLEDevice( - address, - name, - details, - rssi=rssi, - uuids=uuids, - manufacturer_data=manufacturer_data, - service_data=service_data, - delegate=self._manager.central_manager.delegate(), - ) - ) - - return found - # macOS specific methods @property diff --git a/bleak/backends/device.py b/bleak/backends/device.py index d4e5c40e..6ef93f0b 100644 --- a/bleak/backends/device.py +++ b/bleak/backends/device.py @@ -6,7 +6,6 @@ Created on 2018-04-23 by hbldh """ -from ._manufacturers import MANUFACTURERS class BLEDevice: @@ -23,7 +22,7 @@ class BLEDevice: - When using macOS backend, ``details`` attribute will be a CBPeripheral object. """ - def __init__(self, address, name, details=None, rssi=0, **kwargs): + def __init__(self, address, name=None, details=None, rssi=0, **kwargs): #: The Bluetooth address of the device on this machine. self.address = address #: The advertised name of the device. @@ -36,17 +35,7 @@ def __init__(self, address, name, details=None, rssi=0, **kwargs): self.metadata = kwargs def __str__(self): - if not self.name: - if "manufacturer_data" in self.metadata: - ks = list(self.metadata["manufacturer_data"].keys()) - if len(ks): - mf = MANUFACTURERS.get(ks[0], MANUFACTURERS.get(0xFFFF)) - value = self.metadata["manufacturer_data"].get( - ks[0], MANUFACTURERS.get(0xFFFF) - ) - # TODO: Evaluate how to interpret the value of the company identifier... - return "{0}: {1} ({2})".format(self.address, mf, value) - return "{0}: {1}".format(self.address, self.name or "Unknown") + return f"{self.address}: {self.name}" def __repr__(self): - return str(self) + return f"BLEDevice({self.address}, {self.name})" diff --git a/bleak/backends/p4android/scanner.py b/bleak/backends/p4android/scanner.py index 77c63275..7a2dd69b 100644 --- a/bleak/backends/p4android/scanner.py +++ b/bleak/backends/p4android/scanner.py @@ -17,7 +17,6 @@ from jnius import cast, java_method from ...exc import BleakError -from ..device import BLEDevice from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner from . import defs, utils @@ -56,7 +55,6 @@ def __init__( else: self.__scan_mode = defs.ScanSettings.SCAN_MODE_LOW_LATENCY - self._devices = {} self.__adapter = None self.__javascanner = None self.__callback = None @@ -228,31 +226,8 @@ def set_scanning_filter(self, **kwargs): # and ScanSettings java objects to pass to startScan(). raise NotImplementedError("not implemented in Android backend") - @property - def discovered_devices(self) -> List[BLEDevice]: - return [*self._devices.values()] - - -class _PythonScanCallback(utils.AsyncJavaCallbacks): - __javainterfaces__ = ["com.github.hbldh.bleak.PythonScanCallback$Interface"] - - def __init__(self, scanner, loop): - super().__init__(loop) - self._scanner = scanner - self.java = defs.PythonScanCallback(self) - - def result_state(self, status_str, name, *data): - self._loop.call_soon_threadsafe( - self._result_state_unthreadsafe, status_str, name, data - ) - - @java_method("(I)V") - def onScanFailed(self, errorCode): - self.result_state(defs.ScanFailed(errorCode).name, "onScan") - - @java_method("(Landroid/bluetooth/le/ScanResult;)V") - def onScanResult(self, result): - device = result.getDevice() + def _handle_scan_result(self, result): + native_device = result.getDevice() record = result.getScanRecord() service_uuids = record.getServiceUuids() @@ -280,20 +255,44 @@ def onScanResult(self, result): manufacturer_data=manufacturer_data, service_data=service_data, service_uuids=service_uuids, - platform_data=(result,), tx_power=tx_power, + rssi=native_device.getRssi(), + platform_data=(result,), ) - device = BLEDevice( - device.getAddress(), - device.getName(), - rssi=result.getRssi(), - uuids=service_uuids, - manufacturer_data=manufacturer_data, + + device = self.create_or_update_device( + native_device.getAddress(), + native_device.getName(), + native_device, + advertisement, ) - self._scanner._devices[device.address] = device + + if not self._callback: + return + + self._callback(device, advertisement) + + +class _PythonScanCallback(utils.AsyncJavaCallbacks): + __javainterfaces__ = ["com.github.hbldh.bleak.PythonScanCallback$Interface"] + + def __init__(self, scanner: BleakScannerP4Android, loop: asyncio.AbstractEventLoop): + super().__init__(loop) + self._scanner = scanner + self.java = defs.PythonScanCallback(self) + + def result_state(self, status_str, name, *data): + self._loop.call_soon_threadsafe( + self._result_state_unthreadsafe, status_str, name, data + ) + + @java_method("(I)V") + def onScanFailed(self, errorCode): + self.result_state(defs.ScanFailed(errorCode).name, "onScan") + + @java_method("(Landroid/bluetooth/le/ScanResult;)V") + def onScanResult(self, result): + self._loop.call_soon_threadsafe(self._scanner._handle_scan_result, result) + if "onScan" not in self.states: - self.result_state(None, "onScan", device) - if self._scanner._callback: - self._loop.call_soon_threadsafe( - self._scanner._callback, device, advertisement - ) + self.result_state(None, "onScan", result) diff --git a/bleak/backends/p4android/utils.py b/bleak/backends/p4android/utils.py index 6dc50b80..a4fafda3 100644 --- a/bleak/backends/p4android/utils.py +++ b/bleak/backends/p4android/utils.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import asyncio import logging import warnings @@ -13,7 +14,7 @@ class AsyncJavaCallbacks(PythonJavaClass): __javacontext__ = "app" - def __init__(self, loop): + def __init__(self, loop: asyncio.AbstractEventLoop): self._loop = loop self.states = {} self.futures = {} diff --git a/bleak/backends/scanner.py b/bleak/backends/scanner.py index d7d2a6bc..664b4f67 100644 --- a/bleak/backends/scanner.py +++ b/bleak/backends/scanner.py @@ -3,44 +3,71 @@ import inspect import os import platform -from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + NamedTuple, + Optional, + Tuple, + Type, +) from ..exc import BleakError from .device import BLEDevice -class AdvertisementData: +class AdvertisementData(NamedTuple): """ Wrapper around the advertisement data that each platform returns upon discovery """ - def __init__(self, **kwargs): - """ - Keyword Args: - local_name (str): The name of the ble device advertising - manufacturer_data (dict): Manufacturer data from the device - service_data (dict): Service data from the device - service_uuids (list): UUIDs associated with the device - platform_data (tuple): Tuple of platform specific advertisement data - tx_power (int): Transmit power level of the device - """ - # The local name of the device - self.local_name: Optional[str] = kwargs.get("local_name", None) + local_name: Optional[str] + """ + The local name of the device or ``None`` if not included in advertising data. + """ + + manufacturer_data: Dict[int, bytes] + """ + Dictionary of manufacturer data in bytes from the received advertisement data or empty dict if not present. + + The keys are Bluetooth SIG assigned Company Identifiers and the values are bytes. + + https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/ + """ + + service_data: Dict[str, bytes] + """ + Dictionary of service data from the received advertisement data or empty dict if not present. + """ - # Dictionary of manufacturer data in bytes - self.manufacturer_data: Dict[int, bytes] = kwargs.get("manufacturer_data", {}) + service_uuids: List[str] + """ + List of service UUIDs from the received advertisement data or empty list if not present. + """ + + tx_power: Optional[int] + """ + Tx Power data from the received advertising data or ``None`` if not present. + + .. versionadded:: 0.17.0 + """ - # Dictionary of service data - self.service_data: Dict[str, bytes] = kwargs.get("service_data", {}) + rssi: int + """ + The Radio Receive Signal Strength (RSSI) in dBm. - # List of UUIDs - self.service_uuids: List[str] = kwargs.get("service_uuids", []) + .. versionadded:: 0.19.0 + """ - # Tuple of platform specific data - self.platform_data: Tuple = kwargs.get("platform_data", ()) + platform_data: Tuple + """ + Tuple of platform specific data. - # Tx Power data - self.tx_power: Optional[int] = kwargs.get("tx_power") + This is not a stable API. The actual values may change between releases. + """ def __repr__(self) -> str: kwargs = [] @@ -54,6 +81,7 @@ def __repr__(self) -> str: kwargs.append(f"service_uuids={repr(self.service_uuids)}") if self.tx_power is not None: kwargs.append(f"tx_power={repr(self.tx_power)}") + kwargs.append(f"rssi={repr(self.rssi)}") return f"AdvertisementData({', '.join(kwargs)})" @@ -61,11 +89,19 @@ def __repr__(self) -> str: [BLEDevice, AdvertisementData], Optional[Awaitable[None]], ] +""" +Type alias for callback called when advertisement data is received. +""" AdvertisementDataFilter = Callable[ [BLEDevice, AdvertisementData], bool, ] +""" +Type alias for an advertisement data filter function. + +Implementations should return ``True`` for matches, otherwise ``False``. +""" class BaseBleakScanner(abc.ABC): @@ -81,6 +117,13 @@ class BaseBleakScanner(abc.ABC): containing this advertising data will be received. """ + seen_devices: Dict[str, Tuple[BLEDevice, AdvertisementData]] + """ + Map of device identifier to BLEDevice and most recent advertisement data. + + This map must be cleared when scanning starts. + """ + def __init__( self, detection_callback: Optional[AdvertisementDataCallback], @@ -93,6 +136,8 @@ def __init__( [u.lower() for u in service_uuids] if service_uuids is not None else None ) + self.seen_devices = {} + def register_detection_callback( self, callback: Optional[AdvertisementDataCallback] ) -> None: @@ -127,6 +172,45 @@ def detection_callback(s, d): self._callback = detection_callback + def create_or_update_device( + self, address: str, name: str, details: Any, adv: AdvertisementData + ) -> BLEDevice: + """ + Creates or updates a device in :attr:`seen_devices`. + + Args: + address: The Bluetooth address of the device (UUID on macOS). + name: The OS display name for the device. + details: The platform-specific handle for the device. + adv: The most recent advertisement data received. + + Returns: + The updated device. + """ + + # for backwards compatibility, see https://github.com/hbldh/bleak/issues/1025 + metadata = dict( + uuids=adv.service_uuids, + manufacturer_data=adv.manufacturer_data, + ) + + try: + device, _ = self.seen_devices[address] + + device.metadata = metadata + except KeyError: + device = BLEDevice( + address, + name, + details, + adv.rssi, + **metadata, + ) + + self.seen_devices[address] = (device, adv) + + return device + @abc.abstractmethod async def start(self): """Start scanning for devices""" @@ -147,16 +231,6 @@ def set_scanning_filter(self, **kwargs): """ raise NotImplementedError() - @property - @abc.abstractmethod - 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. - """ - raise NotImplementedError() - def get_platform_scanner_backend_type() -> Type[BaseBleakScanner]: """ diff --git a/bleak/backends/winrt/client.py b/bleak/backends/winrt/client.py index 06cb29ae..486abc44 100644 --- a/bleak/backends/winrt/client.py +++ b/bleak/backends/winrt/client.py @@ -48,7 +48,7 @@ from bleak_winrt.windows.storage.streams import Buffer from ... import BleakScanner -from ...exc import PROTOCOL_ERROR_CODES, BleakError +from ...exc import PROTOCOL_ERROR_CODES, BleakError, BleakDeviceNotFoundError from ..characteristic import BleakGATTCharacteristic from ..client import BaseBleakClient, NotifyCallback from ..device import BLEDevice @@ -172,7 +172,8 @@ def __init__( self._device_info = address_or_ble_device.details.adv.bluetooth_address else: self._device_info = None - self._requester = None + self._requester: Optional[BluetoothLEDevice] = None + self._services_changed_events: List[asyncio.Event] = [] self._session_active_events: List[asyncio.Event] = [] self._session_closed_events: List[asyncio.Event] = [] self._session: GattSession = None @@ -189,6 +190,7 @@ def __init__( self._use_cached_services = winrt.get("use_cached_services") self._address_type = winrt.get("address_type", kwargs.get("address_type")) + self._session_services_changed_token: Optional[EventRegistrationToken] = None self._session_status_changed_token: Optional[EventRegistrationToken] = None self._max_pdu_size_changed_token: Optional[EventRegistrationToken] = None @@ -197,7 +199,7 @@ def __str__(self): # Connectivity methods - def _create_requester(self, bluetooth_address: int): + async def _create_requester(self, bluetooth_address: int) -> BluetoothLEDevice: args = [ bluetooth_address, ] @@ -207,7 +209,14 @@ def _create_requester(self, bluetooth_address: int): if self._address_type == "public" else BluetoothAddressType.RANDOM ) - return BluetoothLEDevice.from_bluetooth_address_async(*args) + requester = await BluetoothLEDevice.from_bluetooth_address_async(*args) + + # https://github.com/microsoft/Windows-universal-samples/issues/1089#issuecomment-487586755 + if requester is None: + raise BleakDeviceNotFoundError( + self.address, f"Device with address {self.address} was not found." + ) + return requester async def connect(self, **kwargs) -> bool: """Connect to the specified GATT server. @@ -226,40 +235,59 @@ async def connect(self, **kwargs) -> bool: self.address, timeout=timeout, backend=BleakScannerWinRT ) - if device: - self._device_info = device.details.adv.bluetooth_address - else: - raise BleakError(f"Device with address {self.address} was not found.") + if device is None: + raise BleakDeviceNotFoundError( + self.address, f"Device with address {self.address} was not found." + ) + + self._device_info = device.details.adv.bluetooth_address logger.debug("Connecting to BLE device @ %s", self.address) + loop = asyncio.get_running_loop() + self._requester = await self._create_requester(self._device_info) - if self._requester is None: - # https://github.com/microsoft/Windows-universal-samples/issues/1089#issuecomment-487586755 - raise BleakError( - f"Failed to connect to {self._device_info}. If the device requires pairing, then pair first. If the device uses a random address, it may have changed." - ) + def handle_services_changed(): + if not self._services_changed_events: + logger.warn("%s: unhandled services changed event", self.address) + else: + for event in self._services_changed_events: + event.set() - # Called on disconnect event or on failure to connect. - def handle_disconnect(): - if self._session_status_changed_token: - self._session.remove_session_status_changed( - self._session_status_changed_token - ) - self._session_status_changed_token = None + def services_changed_handler(sender, args): + logger.debug("%s: services changed", self.address) + loop.call_soon_threadsafe(handle_services_changed) - if self._max_pdu_size_changed_token: - self._session.remove_max_pdu_size_changed( - self._max_pdu_size_changed_token - ) - self._max_pdu_size_changed_token = None + self._services_changed_token = self._requester.add_gatt_services_changed( + services_changed_handler + ) + # Called on disconnect event or on failure to connect. + def handle_disconnect(): if self._requester: + if self._services_changed_token: + self._requester.remove_gatt_services_changed( + self._services_changed_token + ) + self._services_changed_token = None + self._requester.close() self._requester = None if self._session: + if self._session_status_changed_token: + self._session.remove_session_status_changed( + self._session_status_changed_token + ) + self._session_status_changed_token = None + + if self._max_pdu_size_changed_token: + self._session.remove_max_pdu_size_changed( + self._max_pdu_size_changed_token + ) + self._max_pdu_size_changed_token = None + self._session.close() self._session = None @@ -282,8 +310,6 @@ def handle_session_status_changed( handle_disconnect() - loop = asyncio.get_running_loop() - # this is the WinRT event handler will be called on another thread def session_status_changed_event_handler( sender: GattSession, args: GattSessionStatusChangedEventArgs @@ -297,7 +323,15 @@ def session_status_changed_event_handler( loop.call_soon_threadsafe(handle_session_status_changed, args) def max_pdu_size_changed_handler(sender: GattSession, args): - logger.debug("max_pdu_size_changed_handler: %d", sender.max_pdu_size) + try: + max_pdu_size = sender.max_pdu_size + except OSError: + # There is a race condition where this event was already + # queued when the GattSession object was closed. In that + # case, we get a Windows error which we can just ignore. + return + + logger.debug("max_pdu_size_changed_handler: %d", max_pdu_size) # Start a GATT Session to connect event = asyncio.Event() @@ -476,9 +510,6 @@ async def unpair(self) -> bool: else _address_to_int(self.address) ) - if device is None: - raise BleakError(f"Device with address {self.address} was not found.") - try: unpairing_result = await device.device_information.pairing.unpair_async() if unpairing_result.status not in ( @@ -527,8 +558,39 @@ async def get_services(self, **kwargs) -> BleakGATTServiceCollection: else BluetoothCacheMode.UNCACHED ) + # if we receive a services changed event before get_gatt_services_async() + # finishes, we need to call it again with BluetoothCacheMode.UNCACHED + # to ensure we have the correct services as described in + # https://learn.microsoft.com/en-us/uwp/api/windows.devices.bluetooth.bluetoothledevice.gattserviceschanged + while True: + services_changed_event = asyncio.Event() + services_changed_event_task = asyncio.create_task( + services_changed_event.wait() + ) + self._services_changed_events.append(services_changed_event) + get_services_task = self._requester.get_gatt_services_async(*args) + + try: + await asyncio.wait( + [services_changed_event_task, get_services_task], + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + services_changed_event_task.cancel() + self._services_changed_events.remove(services_changed_event) + get_services_task.cancel() + + if not services_changed_event.is_set(): + break + + logger.debug( + "%s: restarting get services due to services changed event", + self.address, + ) + args = [BluetoothCacheMode.UNCACHED] + services: Sequence[GattDeviceService] = _ensure_success( - await self._requester.get_gatt_services_async(*args), + get_services_task.get_results(), "services", "Could not get GATT services", ) @@ -570,7 +632,6 @@ async def get_services(self, **kwargs) -> BleakGATTServiceCollection: ) ) - logger.info("Services resolved for %s", str(self)) self._services_resolved = True return self.services diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 1a5f248d..ce1bbfa9 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -16,7 +16,6 @@ else: from typing import Literal -from ..device import BLEDevice from ..scanner import AdvertisementDataCallback, BaseBleakScanner, AdvertisementData from ...assigned_numbers import AdvertisementDataType @@ -24,17 +23,15 @@ logger = logging.getLogger(__name__) -def _format_bdaddr(a): +def _format_bdaddr(a: int) -> str: return ":".join("{:02X}".format(x) for x in a.to_bytes(6, byteorder="big")) -def _format_event_args(e): +def _format_event_args(e: BluetoothLEAdvertisementReceivedEventArgs) -> str: try: - return "{0}: {1}".format( - _format_bdaddr(e.bluetooth_address), e.advertisement.local_name or "Unknown" - ) + return f"{_format_bdaddr(e.bluetooth_address)}: {e.advertisement.local_name}" except Exception: - return e.bluetooth_address + return _format_bdaddr(e.bluetooth_address) class _RawAdvData(NamedTuple): @@ -82,8 +79,8 @@ def __init__( super(BleakScannerWinRT, self).__init__(detection_callback, service_uuids) self.watcher = None + self._advertisement_pairs: Dict[int, _RawAdvData] = {} self._stopped_event = None - self._discovered_devices: Dict[int, _RawAdvData] = {} # case insensitivity is for backwards compatibility on Windows only if scanning_mode.lower() == "passive": @@ -106,43 +103,63 @@ def _received_handler( # TODO: Cannot check for if sender == self.watcher in winrt? logger.debug("Received {0}.".format(_format_event_args(event_args))) - # get the previous advertising data or start a new one - raw_data = self._discovered_devices.get( - event_args.bluetooth_address, _RawAdvData(event_args, None) - ) + # REVISIT: if scanning filters with BluetoothSignalStrengthFilter.OutOfRangeTimeout + # are in place, an RSSI of -127 means that the device has gone out of range and should + # be removed from the list of seen devices instead of processing the advertisement data. + # https://learn.microsoft.com/en-us/uwp/api/windows.devices.bluetooth.bluetoothsignalstrengthfilter.outofrangetimeout + + bdaddr = _format_bdaddr(event_args.bluetooth_address) + + # Unlike other platforms, Windows does not combine advertising data for + # us (regular advertisement + scan response) so we have to do it manually. + + # get the previous advertising data/scan response pair or start a new one + raw_data = self._advertisement_pairs.get(bdaddr, _RawAdvData(None, None)) - # update the advertsing data depending on the advertising data type + # update the advertising data depending on the advertising data type if event_args.advertisement_type == BluetoothLEAdvertisementType.SCAN_RESPONSE: raw_data = _RawAdvData(raw_data.adv, event_args) else: raw_data = _RawAdvData(event_args, raw_data.scan) - self._discovered_devices[event_args.bluetooth_address] = raw_data - - if self._callback is None: + self._advertisement_pairs[bdaddr] = raw_data + + # if we are expecting scan response and we haven't received both a + # regular advertisement and a scan response, don't do callbacks yet, + # wait until we have both instead so we get a callback with partial data + + if (raw_data.adv is None or raw_data.scan is None) and ( + event_args.advertisement_type + in [ + BluetoothLEAdvertisementType.CONNECTABLE_UNDIRECTED, + BluetoothLEAdvertisementType.SCANNABLE_UNDIRECTED, + BluetoothLEAdvertisementType.SCAN_RESPONSE, + ] + ): + logger.debug("skipping callback, waiting for scan response") return - # Get a "BLEDevice" from parse_event args - device = self._parse_adv_data(raw_data) + uuids = [] + mfg_data = {} + service_data = {} + local_name = None + tx_power = None - # On Windows, we have to fake service UUID filtering. If we were to pass - # a BluetoothLEAdvertisementFilter to the BluetoothLEAdvertisementWatcher - # with the service UUIDs appropriately set, we would no longer receive - # scan response data (which commonly contains the local device name). - # So we have to do it like this instead. + for args in filter(lambda d: d is not None, raw_data): + for u in args.advertisement.service_uuids: + uuids.append(str(u)) - if self._service_uuids: - for uuid in device.metadata["uuids"]: - if uuid in self._service_uuids: - break - else: - # if there were no matching service uuids, the don't call the callback - return + for m in args.advertisement.manufacturer_data: + mfg_data[m.company_id] = bytes(m.data) - service_data = {} + # local name is empty string rather than None if not present + if args.advertisement.local_name: + local_name = args.advertisement.local_name - # Decode service data - for args in filter(lambda d: d is not None, raw_data): + if args.transmit_power_level_in_d_bm is not None: + tx_power = raw_data.adv.transmit_power_level_in_d_bm + + # Decode service data for section in args.advertisement.get_sections_by_type( AdvertisementDataType.SERVICE_DATA_UUID16 ): @@ -163,32 +180,52 @@ def _received_handler( data = bytes(section.data) service_data[str(UUID(bytes=bytes(data[15::-1])))] = data[16:] - # get transmit power level data - tx_power = raw_data.adv.transmit_power_level_in_d_bm - # Use the BLEDevice to populate all the fields for the advertisement data to return advertisement_data = AdvertisementData( - local_name=device.name, - manufacturer_data=device.metadata["manufacturer_data"], + local_name=local_name, + manufacturer_data=mfg_data, service_data=service_data, - service_uuids=device.metadata["uuids"], - platform_data=(sender, raw_data), + service_uuids=uuids, tx_power=tx_power, + rssi=event_args.raw_signal_strength_in_d_bm, + platform_data=(sender, raw_data), ) + device = self.create_or_update_device( + bdaddr, local_name, raw_data, advertisement_data + ) + + if self._callback is None: + return + + # On Windows, we have to fake service UUID filtering. If we were to pass + # a BluetoothLEAdvertisementFilter to the BluetoothLEAdvertisementWatcher + # with the service UUIDs appropriately set, we would no longer receive + # scan response data (which commonly contains the local device name). + # So we have to do it like this instead. + + if self._service_uuids: + for uuid in uuids: + if uuid in self._service_uuids: + break + else: + # if there were no matching service uuids, the don't call the callback + return + self._callback(device, advertisement_data) def _stopped_handler(self, sender, e): logger.debug( "{0} devices found. Watcher status: {1}.".format( - len(self._discovered_devices), self.watcher.status + len(self.seen_devices), self.watcher.status ) ) self._stopped_event.set() async def start(self): # start with fresh list of discovered devices - self._discovered_devices.clear() + self.seen_devices = {} + self._advertisement_pairs.clear() self.watcher = BluetoothLEAdvertisementWatcher() self.watcher.scanning_mode = self._scanning_mode @@ -243,31 +280,3 @@ def set_scanning_filter(self, **kwargs): if "AdvertisementFilter" in kwargs: # TODO: Handle AdvertisementFilter parameters self._advertisement_filter = kwargs["AdvertisementFilter"] - - @property - def discovered_devices(self) -> List[BLEDevice]: - return [self._parse_adv_data(d) for d in self._discovered_devices.values()] - - @staticmethod - def _parse_adv_data(raw_data: _RawAdvData) -> BLEDevice: - """ - Combines advertising data from regular advertisement data and scan response. - """ - bdaddr = _format_bdaddr(raw_data.adv.bluetooth_address) - uuids = [] - data = {} - local_name = None - - for args in filter(lambda d: d is not None, raw_data): - for u in args.advertisement.service_uuids: - uuids.append(str(u)) - for m in args.advertisement.manufacturer_data: - data[m.company_id] = bytes(m.data) - # local name is empty string rather than None if not present - if args.advertisement.local_name: - local_name = args.advertisement.local_name - rssi = args.raw_signal_strength_in_d_bm - - return BLEDevice( - bdaddr, local_name, raw_data, rssi, uuids=uuids, manufacturer_data=data - ) diff --git a/bleak/exc.py b/bleak/exc.py index 1eba5fc4..2126de15 100644 --- a/bleak/exc.py +++ b/bleak/exc.py @@ -8,6 +8,25 @@ class BleakError(Exception): pass +class BleakDeviceNotFoundError(BleakError): + """ + Exception which is raised if a device can not be found by ``connect``, ``pair`` and ``unpair``. + This is the case if the OS Bluetooth stack has never seen this device or it was removed and forgotten. + + .. versionadded: 0.19.0 + """ + + identifier: str + + def __init__(self, identifier: str, *args: object) -> None: + """ + Args: + identifier (str): device identifier (Bluetooth address or UUID) of the device which was not found + """ + super().__init__(*args) + self.identifier = identifier + + class BleakDBusError(BleakError): """Specialized exception type for D-Bus errors.""" diff --git a/docs/api/scanner.rst b/docs/api/scanner.rst index 3d8a5375..100b7cbf 100644 --- a/docs/api/scanner.rst +++ b/docs/api/scanner.rst @@ -2,6 +2,8 @@ BleakScanner class ================== +.. py:currentmodule:: bleak + .. autoclass:: bleak.BleakScanner @@ -14,7 +16,6 @@ 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 @@ -29,7 +30,7 @@ and stop scanning is to use it in an ``async with`` statement:: import asyncio from bleak import BleakScanner - def main(): + async def main(): stop_event = asyncio.Event() # TODO: add something that calls stop_event.set() @@ -56,6 +57,21 @@ following methods: .. automethod:: bleak.BleakScanner.start .. automethod:: bleak.BleakScanner.stop +------------------------------------------------- +Getting discovered devices and advertisement data +------------------------------------------------- + +If you aren't using the "easy" class methods, there are two ways to get the +discovered devices and advertisement data. + +For event-driven programming, you can provide a ``detection_callback`` callback +to the :class:`BleakScanner` constructor. This will be called back each time +and advertisement is received. + +Otherwise, you can use one of the properties below after scanning has stopped. + +.. autoproperty:: bleak.BleakScanner.discovered_devices +.. autoproperty:: bleak.BleakScanner.discovered_devices_and_advertisement_data ---------- Deprecated diff --git a/docs/backends/android.rst b/docs/backends/android.rst index bebb04ef..71f9d8c8 100644 --- a/docs/backends/android.rst +++ b/docs/backends/android.rst @@ -1,8 +1,9 @@ Android backend =============== -Quick-start: see the `example README <../../examples/kivy/README>`_. Buildozer -will compile an app and upload it to a device. +For an example of building an android bluetooth app, see +`the example folder `_ +and its accompanying README file. There are a handful of ways to run Python on Android. Presently some code has been written for the `Python-for-Android `_ @@ -55,9 +56,6 @@ be added to the android application in the buildozer.spec file, and are also requested from the user at runtime. This means that enabling bluetooth may not 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 --- diff --git a/docs/backends/linux.rst b/docs/backends/linux.rst index 6e70504b..f650766f 100644 --- a/docs/backends/linux.rst +++ b/docs/backends/linux.rst @@ -3,12 +3,10 @@ Linux backend ============= -The Linux backend of Bleak is written using the -`TxDBus `_ -package. It is written for -`Twisted `_, but by using the -`twisted.internet.asyncioreactor `_ -one can use it with `asyncio`. +The Linux backend of Bleak communicates with `BlueZ `_ +over DBus. Communication uses the `dbus-fast +`_ package for async access to +DBus messaging. Special handling for ``write_gatt_char`` @@ -33,6 +31,15 @@ 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. +Parallel Access +--------------- + +Each Bleak object should be created and used from a single `asyncio event +loop`_. Simple asyncio programs will only have a single event loop. It's also +possible to use Bleak with multiple event loops, even at the same time, but +individual Bleak objects should not be shared between event loops. Otherwise, +RuntimeErrors similar to ``[...] got Future attached to a +different loop`` will be thrown. API --- @@ -48,3 +55,5 @@ Client .. automodule:: bleak.backends.bluezdbus.client :members: + +.. _`asyncio event loop`: https://docs.python.org/3/library/asyncio-eventloop.html diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 6fb71296..0b139522 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -43,6 +43,11 @@ the *Privacy* settings in the macOS *System Preferences*. .. image:: images/macos-privacy-bluetooth.png +If the app is already in the list but the checkbox for Bluetooth is disabled, +you will get the a ``BleakError``: "BLE is not authorized - check macOS privacy settings". +instead of crashing with ``SIGABRT``, in which case you need to check the box +to allow Bluetooth for the app that is running Python. + No devices found when scanning on macOS 12 ========================================== diff --git a/examples/detection_callback.py b/examples/detection_callback.py index 1ed16707..7aae77d8 100644 --- a/examples/detection_callback.py +++ b/examples/detection_callback.py @@ -20,7 +20,7 @@ def simple_callback(device: BLEDevice, advertisement_data: AdvertisementData): - logger.info(f"{device.address} RSSI: {device.rssi}, {advertisement_data}") + logger.info(f"{device.address}: {advertisement_data}") async def main(service_uuids): diff --git a/examples/discover.py b/examples/discover.py index 84c05ba7..56d556cd 100644 --- a/examples/discover.py +++ b/examples/discover.py @@ -14,9 +14,15 @@ async def main(): - devices = await BleakScanner.discover() - for d in devices: + print("scanning for 5 seconds, please wait...") + + devices = await BleakScanner.discover(return_adv=True) + + for d, a in devices.values(): + print() print(d) + print("-" * len(str(d))) + print(a) if __name__ == "__main__": diff --git a/examples/kivy/main.py b/examples/kivy/main.py index fa18f5d2..2be33704 100644 --- a/examples/kivy/main.py +++ b/examples/kivy/main.py @@ -52,7 +52,7 @@ async def example(self): scanned_devices.sort(key=lambda device: -device.rssi) for device in scanned_devices: - self.line(f"{device.name} {device.rssi}dB") + self.line(f"{device.name} ({device.address})") for device in scanned_devices: self.line(f"Connecting to {device.name} ...") diff --git a/poetry.lock b/poetry.lock index 5065dab4..0800aa05 100644 --- a/poetry.lock +++ b/poetry.lock @@ -136,14 +136,17 @@ toml = ["tomli"] [[package]] name = "dbus-fast" -version = "1.4.0" +version = "1.22.0" description = "A faster version of dbus-next" category = "main" optional = false python-versions = ">=3.7,<4.0" +[package.dependencies] +async-timeout = ">=3.0.0" + [package.extras] -docs = ["Sphinx[docs] (>=5.1.1,<6.0.0)", "myst-parser[docs] (>=0.18.0,<0.19.0)", "sphinx-rtd-theme[docs] (>=1.0.0,<2.0.0)", "sphinxcontrib-asyncio[docs] (>=0.3.0,<0.4.0)", "sphinxcontrib-fulltoc[docs] (>=1.2.0,<2.0.0)"] +docs = ["Sphinx (>=5.1.1,<6.0.0)", "myst-parser (>=0.18.0,<0.19.0)", "sphinx-rtd-theme (>=1.0.0,<2.0.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinxcontrib-fulltoc (>=1.2.0,<2.0.0)"] [[package]] name = "docutils" @@ -328,7 +331,7 @@ plugins = ["importlib-metadata"] [[package]] name = "pyobjc-core" -version = "8.5" +version = "8.5.1" description = "Python<->ObjC Interoperability Module" category = "main" optional = false @@ -336,37 +339,37 @@ python-versions = ">=3.6" [[package]] name = "pyobjc-framework-Cocoa" -version = "8.5" +version = "8.5.1" description = "Wrappers for the Cocoa frameworks on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=8.5" +pyobjc-core = ">=8.5.1" [[package]] name = "pyobjc-framework-CoreBluetooth" -version = "8.5" +version = "8.5.1" description = "Wrappers for the framework CoreBluetooth on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=8.5" -pyobjc-framework-Cocoa = ">=8.5" +pyobjc-core = ">=8.5.1" +pyobjc-framework-Cocoa = ">=8.5.1" [[package]] name = "pyobjc-framework-libdispatch" -version = "8.5" +version = "8.5.1" description = "Wrappers for libdispatch on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=8.5" +pyobjc-core = ">=8.5.1" [[package]] name = "pyparsing" @@ -634,7 +637,7 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "9f197cee707510b33951a1c160d32e68ec86ceda7c82897971d39295d55f9ee3" +content-hash = "de7999d44b4fa4c454de85be9523558c9d375983122f54d97a97440fb25e558a" [metadata.files] alabaster = [ @@ -764,8 +767,34 @@ coverage = [ {file = "coverage-6.4.4.tar.gz", hash = "sha256:e16c45b726acb780e1e6f88b286d3c10b3914ab03438f32117c4aa52d7f30d58"}, ] dbus-fast = [ - {file = "dbus-fast-1.4.0.tar.gz", hash = "sha256:292fec563d27f39bdc46d0a7d03320ff2d77f5f336bd8f564fe186946a213764"}, - {file = "dbus_fast-1.4.0-py3-none-any.whl", hash = "sha256:3fea12c425587bb2b552e60e40bfcda2d3b94e77a8b2a30ec07c5bbc187bc984"}, + {file = "dbus-fast-1.22.0.tar.gz", hash = "sha256:de6936cd4f70eb094051167d2faa1547c1088966f60f444e5732d1c6315fa64b"}, + {file = "dbus_fast-1.22.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a61153714e637492de9935ae1a0c6c6e7a2d470eb8b6b97b76702ab04216460f"}, + {file = "dbus_fast-1.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96a93b08d7dd896734e6a022ce631906e773a9d10d397f26f96906583c7020a9"}, + {file = "dbus_fast-1.22.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f3667a2c2753aafa042dbe252ad57e562c2e2fe2e9556042b376561d660ad176"}, + {file = "dbus_fast-1.22.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cbc8c6e2b62987dbb48aa22f8c6d3d48e53d04629773d58a3f34342920cac251"}, + {file = "dbus_fast-1.22.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:f072d5e7400e5956a56ead71c92a975b0438b36e7694b3d10afe1af9ede456d9"}, + {file = "dbus_fast-1.22.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86d5abdddd19deef483a7e5628fff073d34b050e9edc462bf0246da04e754006"}, + {file = "dbus_fast-1.22.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f2036ffd7f4fdf9d7343a99aa569e2c5c8d15cbe915c9b4f493736b277bfae89"}, + {file = "dbus_fast-1.22.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dc3323ec405a0f5660dbf8dd632011e56a3a01040316a5cec9c062a0a930aadf"}, + {file = "dbus_fast-1.22.0-cp37-cp37m-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:327b3903470ce66075952ab6f59abf5a3d81b6a34ca76f5afd0295a224079b96"}, + {file = "dbus_fast-1.22.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7367fe0d25d2f7f2e7e4da65527e03b5f05295e2f8d3189bdda6b5cdcf41079"}, + {file = "dbus_fast-1.22.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:49def23a08f459af4cd8e9170fda7ac191b8577108d355a1914c7c862dfa6eb9"}, + {file = "dbus_fast-1.22.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:31a6ebd1bad2fa9681482f56071d7fab7e21e5f2e280460ef90bf98d48a0addf"}, + {file = "dbus_fast-1.22.0-cp38-cp38-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:33a79590a38ee0956e8e5f89dd7fb230743a2b630a83e6636440e90000274eaf"}, + {file = "dbus_fast-1.22.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf40664a8d647f09687d1ea9bcac959f78eaf8afca90cff7faa0a655307fe7b"}, + {file = "dbus_fast-1.22.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0a0e569caffc67614a033e2841b13a123537ff4959e10a571d3cb1fd07b1ed6c"}, + {file = "dbus_fast-1.22.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a7b0c733fad74be2864dfbc54be39e6efac1499ebeddacd9f8d3ca8cabc54816"}, + {file = "dbus_fast-1.22.0-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:d7141f4d550c8b52a005087a54880727b52fbaeafc6850481f7a4aa9fd7fd32b"}, + {file = "dbus_fast-1.22.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9f7c977714608de03d5c98851ae2f95b86f06f6bca0a94868653ba284ba305b"}, + {file = "dbus_fast-1.22.0-cp39-cp39-manylinux_2_31_x86_64.whl", hash = "sha256:905730fd9ca77ff0e7c7927b58542a07169ad75712363bab2281e5c2b807dc86"}, + {file = "dbus_fast-1.22.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a9fc041966d5422b77c5814a42b495157bf3626902a50a803c4a815f85f035aa"}, + {file = "dbus_fast-1.22.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8e0de8daa007ab6d24237d53d9198ddcea43d30ae4d942565dc7d0b6decd791"}, + {file = "dbus_fast-1.22.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:8321e36f3cfce490ad0ac278a6a79b5aad5542c9c7224c7d131fa156b093b0a9"}, + {file = "dbus_fast-1.22.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4d1a55211a0f81a8701d48b2d2a71747e70a45bde042e1dabb7d5db024dbaa0"}, + {file = "dbus_fast-1.22.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:71ecafd6b994a19bdfa4d34c88b7917d7bc3fcae86b26007ff10dcea41a67afd"}, + {file = "dbus_fast-1.22.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:943fcdcd0aef6923e1da6472abf64f3cca6d47b6f733b464887821032889c673"}, + {file = "dbus_fast-1.22.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0da4f7288236f89c9d25a4ba56a9f3990ae92f1369dfdfa3dad2fc6c9df7de74"}, + {file = "dbus_fast-1.22.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0b037c4244002bcf7ae93b0e9eff3533b5feaa6b658ee64c432773afd6c0167"}, ] docutils = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, @@ -878,37 +907,40 @@ Pygments = [ {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, ] pyobjc-core = [ - {file = "pyobjc-core-8.5.tar.gz", hash = "sha256:704c275439856c0d1287469f0d589a7d808d48b754a93d9ce5415d4eaf06d576"}, - {file = "pyobjc_core-8.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0c234143b48334443f5adcf26e668945a6d47bc1fa6223e80918c6c735a029d9"}, - {file = "pyobjc_core-8.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1486ee533f0d76f666804ce89723ada4db56bfde55e56151ba512d3f849857f8"}, - {file = "pyobjc_core-8.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:412de06dfa728301c04b3e46fd7453320a8ae8b862e85236e547cd797a73b490"}, - {file = "pyobjc_core-8.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b3e09cccb1be574a82cc9f929ae27fc4283eccc75496cb5d51534caa6bb83a3"}, - {file = "pyobjc_core-8.5-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:eeafe21f879666ab7f57efcc6b007c9f5f8733d367b7e380c925203ed83f000d"}, - {file = "pyobjc_core-8.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c0071686976d7ea8c14690950e504a13cb22b4ebb2bc7b5ec47c1c1c0f6eff41"}, + {file = "pyobjc-core-8.5.1.tar.gz", hash = "sha256:f8592a12de076c27006700c4a46164478564fa33d7da41e7cbdd0a3bf9ddbccf"}, + {file = "pyobjc_core-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b62dcf987cc511188fc2aa5b4d3b9fd895361ea4984380463497ce4b0752ddf4"}, + {file = "pyobjc_core-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0accc653501a655f66c13f149a1d3d30e6cb65824edf852f7960a00c4f930d5b"}, + {file = "pyobjc_core-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f82b32affc898e9e5af041c1cecde2c99f2ce160b87df77f678c99f1550a4655"}, + {file = "pyobjc_core-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f7b2f6b6f3caeb882c658fe0c7098be2e8b79893d84daa8e636cb3e58a07df00"}, + {file = "pyobjc_core-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:872c0202c911a5a2f1269261c168e36569f6ddac17e5d854ac19e581726570cc"}, + {file = "pyobjc_core-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:21f92e231a4bae7f2d160d065f5afbf5e859a1e37f29d34ac12592205fc8c108"}, + {file = "pyobjc_core-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:315334dd09781129af6a39641248891c4caa57043901750b0139c6614ce84ec0"}, ] pyobjc-framework-Cocoa = [ - {file = "pyobjc-framework-Cocoa-8.5.tar.gz", hash = "sha256:569bd3a020f64b536fb2d1c085b37553e50558c9f907e08b73ffc16ae68e1861"}, - {file = "pyobjc_framework_Cocoa-8.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7a7c160416696bf6035dfcdf0e603aaa52858d6afcddfcc5ab41733619ac2529"}, - {file = "pyobjc_framework_Cocoa-8.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6ceba444282030be8596b812260e8d28b671254a51052ad778d32da6e17db847"}, - {file = "pyobjc_framework_Cocoa-8.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f46b2b161b8dd40c7b9e00bc69636c3e6480b2704a69aee22ee0154befbe163a"}, - {file = "pyobjc_framework_Cocoa-8.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b31d425aee8698cbf62b187338f5ca59427fa4dca2153a73866f7cb410713119"}, - {file = "pyobjc_framework_Cocoa-8.5-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:898359ac1f76eedec8aa156847682378a8950824421c40edb89391286e607dc4"}, - {file = "pyobjc_framework_Cocoa-8.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:baa2947f76b119a3360973d74d57d6dada87ac527bab9a88f31596af392f123c"}, + {file = "pyobjc-framework-Cocoa-8.5.1.tar.gz", hash = "sha256:9a3de5cdb4644e85daf53f2ed912ef6c16ea5804a9e65552eafe62c2e139eb8c"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aa572acc2628488a47be8d19f4701fc96fce7377cc4da18316e1e08c3918521a"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cb3ae21c8d81b7f02a891088c623cef61bca89bd671eff58c632d2f926b649f3"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:88f08f5bd94c66d373d8413c1d08218aff4cff0b586e0cc4249b2284023e7577"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:063683b57e4bd88cb0f9631ae65d25ec4eecf427d2fe8d0c578f88da9c896f3f"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8f8806ddfac40620fb27f185d0f8937e69e330617319ecc2eccf6b9c8451bdd1"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7733a9a201df9e0cc2a0cf7bf54d76bd7981cba9b599353b243e3e0c9eefec10"}, + {file = "pyobjc_framework_Cocoa-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f0ab227f99d3e25dd3db73f8cde0999914a5f0dd6a08600349d25f95eaa0da63"}, ] pyobjc-framework-CoreBluetooth = [ - {file = "pyobjc-framework-CoreBluetooth-8.5.tar.gz", hash = "sha256:d83928083b0fc1aa9f653b3bbc4c22558559adbd82689aa461f4ccb295669dd2"}, - {file = "pyobjc_framework_CoreBluetooth-8.5-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:19b42a2020ee36e2b0e9b8ae64fb130084a609612fcdedafea7cb53234d3cb63"}, - {file = "pyobjc_framework_CoreBluetooth-8.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ef319beb88d8e75af61eb8f0fef01c6fe186d9a271718bcfaafc68c599af8c37"}, - {file = "pyobjc_framework_CoreBluetooth-8.5-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:57ae20e1dc54b8f0805cda8681a5293b11a499d053df5bdae459d4ca8c67756c"}, + {file = "pyobjc-framework-CoreBluetooth-8.5.1.tar.gz", hash = "sha256:b4f621fc3b5bf289db58e64fd746773b18297f87a0ffc5502de74f69133301c1"}, + {file = "pyobjc_framework_CoreBluetooth-8.5.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:bc720f2987a4d28dc73b13146e7c104d717100deb75c244da68f1d0849096661"}, + {file = "pyobjc_framework_CoreBluetooth-8.5.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2167f22886beb5b3ae69e475e055403f28eab065c49a25e2b98b050b483be799"}, + {file = "pyobjc_framework_CoreBluetooth-8.5.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:aa9587a36eca143701731e8bb6c369148f8cc48c28168d41e7323828e5117f2d"}, ] pyobjc-framework-libdispatch = [ - {file = "pyobjc-framework-libdispatch-8.5.tar.gz", hash = "sha256:f610a0e57e9bb31878776db0a1c1cfd279f4e43e26e3195c6647786b4522b1c4"}, - {file = "pyobjc_framework_libdispatch-8.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:63808d104951f7f721be6ad3de194f23c8e2f7a93c771e07961c63d70f2628c3"}, - {file = "pyobjc_framework_libdispatch-8.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8c67972b6e068ce168611852742cbabe59cbb4a33d3750a351171a7c062771f9"}, - {file = "pyobjc_framework_libdispatch-8.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5a1cc3bb6014c28a4223ad9f2257057f7d2861087c87c81c9649813fc7ec43a6"}, - {file = "pyobjc_framework_libdispatch-8.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c22da1d79a5b0b22e3f040f40c63c51cd46e275a82f143cf2630f772ffc1a4ef"}, - {file = "pyobjc_framework_libdispatch-8.5-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:05d20953557924f4280a6186600600cf5ea4391f5612b43155b3b2a7dfda6b61"}, - {file = "pyobjc_framework_libdispatch-8.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fa9d4b446dc62c15fd04c65decbb36530bf866231f2630262a6435e11d53bf77"}, + {file = "pyobjc-framework-libdispatch-8.5.1.tar.gz", hash = "sha256:066fb34fceb326307559104d45532ec2c7b55426f9910b70dbefd5d1b8fd530f"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a316646ab30ba2a97bc828f8e27e7bb79efdf993d218a9c5118396b4f81dc762"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7730a29e4d9c7d8c2e8d9ffb60af0ab6699b2186296d2bff0a2dd54527578bc3"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:76208d9d2b0071df2950800495ac0300360bb5f25cbe9ab880b65cb809764979"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1ad9aa4773ff1d89bf4385c081824c4f8708b50e3ac2fe0a9d590153242c0f67"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:81e1833bd26f15930faba678f9efdffafc79ec04e2ea8b6d1b88cafc0883af97"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:73226e224436eb6383e7a8a811c90ed597995adb155b4f46d727881a383ac550"}, + {file = "pyobjc_framework_libdispatch-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d115355ce446fc073c75cedfd7ab0a13958adda8e3a3b1e421e1f1e5f65640da"}, ] pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, diff --git a/pyproject.toml b/pyproject.toml index 3100e7ae..b35a0a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bleak" -version = "0.18.1" +version = "0.19.0" description = "Bluetooth Low Energy platform Agnostic Klient" authors = ["Henrik Blidh "] license = "MIT" @@ -25,11 +25,11 @@ classifiers = [ python = "^3.7" async-timeout = ">= 3.0.0, < 5" typing-extensions = { version = "^4.2.0", python = "<3.8" } -pyobjc-core = { version = "^8.5", markers = "platform_system=='Darwin'" } -pyobjc-framework-CoreBluetooth = { version = "^8.5", markers = "platform_system=='Darwin'" } -pyobjc-framework-libdispatch = { version = "^8.5", markers = "platform_system=='Darwin'" } -bleak-winrt = { version = "^1.1.1", markers = "platform_system=='Windows'" } -dbus-fast = { version = "^1.4.0", markers = "platform_system == 'Linux'" } +pyobjc-core = { version = "^8.5.1", markers = "platform_system=='Darwin'" } +pyobjc-framework-CoreBluetooth = { version = "^8.5.1", markers = "platform_system=='Darwin'" } +pyobjc-framework-libdispatch = { version = "^8.5.1", markers = "platform_system=='Darwin'" } +bleak-winrt = { version = "^1.2.0", markers = "platform_system=='Windows'" } +dbus-fast = { version = "^1.22.0", markers = "platform_system == 'Linux'" } [tool.poetry.group.docs.dependencies] Sphinx = { version = "^5.1.1", python = ">=3.8" }