diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 77f18e30..c2d9b43c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,22 @@ and this project adheres to `Semantic Versioning None: path: The D-Bus object path of the device. props: The D-Bus object properties of the device. """ + _service_uuids = props.get("UUIDs", []) + + if not self.is_allowed_uuid(_service_uuids): + return # Get all the information wanted to pack in the advertisement data _local_name = props.get("Name") @@ -244,7 +248,6 @@ def _handle_advertising_data(self, path: str, props: Device1) -> None: k: bytes(v) for k, v in props.get("ManufacturerData", {}).items() } _service_data = {k: bytes(v) for k, v in props.get("ServiceData", {}).items()} - _service_uuids = props.get("UUIDs", []) # Get tx power data tx_power = props.get("TxPower") diff --git a/bleak/backends/characteristic.py b/bleak/backends/characteristic.py index f83917a8..eca52d5e 100644 --- a/bleak/backends/characteristic.py +++ b/bleak/backends/characteristic.py @@ -7,7 +7,7 @@ """ import abc import enum -from typing import Any, List, Union +from typing import Any, Callable, List, Union from uuid import UUID from ..uuids import uuidstr_to_str @@ -30,7 +30,7 @@ class GattCharacteristicsFlags(enum.Enum): class BleakGATTCharacteristic(abc.ABC): """Interface for the Bleak representation of a GATT Characteristic""" - def __init__(self, obj: Any, max_write_without_response_size: int): + def __init__(self, obj: Any, max_write_without_response_size: Callable[[], int]): """ Args: obj: @@ -86,12 +86,30 @@ 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``. + In rare cases, a device may take a long time to update this value, so + reading this property may return the default value of ``20`` and reading + it again after a some time may return the expected higher value. + + If you *really* need to wait for a higher value, you can do something + like this: + + .. code-block:: python + + async with asyncio.timeout(10): + while char.max_write_without_response_size == 20: + await asyncio.sleep(0.5) + .. warning:: Linux quirk: For BlueZ versions < 5.62, this property will always return ``20``. .. versionadded:: 0.16 """ - return self._max_write_without_response_size + + # for backwards compatibility + if isinstance(self._max_write_without_response_size, int): + return self._max_write_without_response_size + + return self._max_write_without_response_size() @property @abc.abstractmethod diff --git a/bleak/backends/corebluetooth/characteristic.py b/bleak/backends/corebluetooth/characteristic.py index 116c7267..4bf61711 100644 --- a/bleak/backends/corebluetooth/characteristic.py +++ b/bleak/backends/corebluetooth/characteristic.py @@ -6,7 +6,7 @@ """ from enum import Enum -from typing import Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union from CoreBluetooth import CBCharacteristic @@ -59,7 +59,9 @@ class CBCharacteristicProperties(Enum): class BleakGATTCharacteristicCoreBluetooth(BleakGATTCharacteristic): """GATT Characteristic implementation for the CoreBluetooth backend""" - def __init__(self, obj: CBCharacteristic, max_write_without_response_size: int): + def __init__( + self, obj: CBCharacteristic, max_write_without_response_size: Callable[[], int] + ): super().__init__(obj, max_write_without_response_size) self.__descriptors: List[BleakGATTDescriptorCoreBluetooth] = [] # self.__props = obj.properties() diff --git a/bleak/backends/corebluetooth/client.py b/bleak/backends/corebluetooth/client.py index 470fc983..a682dadb 100644 --- a/bleak/backends/corebluetooth/client.py +++ b/bleak/backends/corebluetooth/client.py @@ -237,7 +237,7 @@ async def get_services(self, **kwargs) -> BleakGATTServiceCollection: services.add_characteristic( BleakGATTCharacteristicCoreBluetooth( characteristic, - self._peripheral.maximumWriteValueLengthForType_( + lambda: self._peripheral.maximumWriteValueLengthForType_( CBCharacteristicWriteWithoutResponse ), ) diff --git a/bleak/backends/corebluetooth/scanner.py b/bleak/backends/corebluetooth/scanner.py index 6452a28d..3491577d 100644 --- a/bleak/backends/corebluetooth/scanner.py +++ b/bleak/backends/corebluetooth/scanner.py @@ -92,6 +92,13 @@ async def start(self) -> None: def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None: + service_uuids = [ + cb_uuid_to_str(u) for u in a.get("kCBAdvDataServiceUUIDs", []) + ] + + if not self.is_allowed_uuid(service_uuids): + return + # Process service data service_data_dict_raw = a.get("kCBAdvDataServiceData", {}) service_data = { @@ -108,10 +115,6 @@ def callback(p: CBPeripheral, a: Dict[str, Any], r: int) -> None: manufacturer_value = bytes(manufacturer_binary_data[2:]) manufacturer_data[manufacturer_id] = manufacturer_value - service_uuids = [ - cb_uuid_to_str(u) for u in a.get("kCBAdvDataServiceUUIDs", []) - ] - # set tx_power data if available tx_power = a.get("kCBAdvDataTxPowerLevel") diff --git a/bleak/backends/p4android/characteristic.py b/bleak/backends/p4android/characteristic.py index 562eb6ea..d9f6f191 100644 --- a/bleak/backends/p4android/characteristic.py +++ b/bleak/backends/p4android/characteristic.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import Callable, List, Union from uuid import UUID from ...exc import BleakError @@ -15,7 +15,7 @@ def __init__( java, service_uuid: str, service_handle: int, - max_write_without_response_size: int, + max_write_without_response_size: Callable[[], int], ): super(BleakGATTCharacteristicP4Android, self).__init__( java, max_write_without_response_size diff --git a/bleak/backends/p4android/client.py b/bleak/backends/p4android/client.py index 92d51075..f1bca4dd 100644 --- a/bleak/backends/p4android/client.py +++ b/bleak/backends/p4android/client.py @@ -273,7 +273,7 @@ async def get_services(self) -> BleakGATTServiceCollection: java_characteristic, service.uuid, service.handle, - self.__mtu - 3, + lambda: self.__mtu - 3, ) services.add_characteristic(characteristic) diff --git a/bleak/backends/p4android/defs.py b/bleak/backends/p4android/defs.py index f5245450..832f92bc 100644 --- a/bleak/backends/p4android/defs.py +++ b/bleak/backends/p4android/defs.py @@ -5,6 +5,7 @@ from jnius import autoclass, cast import bleak.exc +from bleak.uuids import normalize_uuid_16 # caching constants avoids unnecessary extra use of the jni-python interface, which can be slow @@ -87,4 +88,4 @@ class ScanFailed(enum.IntEnum): BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE: "write-without-response", } -CLIENT_CHARACTERISTIC_CONFIGURATION_UUID = "2902" +CLIENT_CHARACTERISTIC_CONFIGURATION_UUID = normalize_uuid_16(0x2902) diff --git a/bleak/backends/p4android/scanner.py b/bleak/backends/p4android/scanner.py index c9a6070d..fb3be74b 100644 --- a/bleak/backends/p4android/scanner.py +++ b/bleak/backends/p4android/scanner.py @@ -233,6 +233,9 @@ def _handle_scan_result(self, result) -> None: if service_uuids is not None: service_uuids = [service_uuid.toString() for service_uuid in service_uuids] + if not self.is_allowed_uuid(service_uuids): + return + manufacturer_data = record.getManufacturerSpecificData() manufacturer_data = { manufacturer_data.keyAt(index): bytes(manufacturer_data.valueAt(index)) diff --git a/bleak/backends/scanner.py b/bleak/backends/scanner.py index 693b20db..eb0d71f0 100644 --- a/bleak/backends/scanner.py +++ b/bleak/backends/scanner.py @@ -197,32 +197,51 @@ def remove() -> None: return remove - def call_detection_callbacks( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: + def is_allowed_uuid(self, service_uuids: Optional[List[str]]) -> bool: """ - Calls all registered detection callbacks. + Check if the advertisement data contains any of the service UUIDs + matching the filter. If no filter is set, this will always return + ``True``. - Backend implementations should call this method when an advertisement - event is received from the OS. - """ + Args: + service_uuids: The service UUIDs from the advertisement data. + Returns: + ``True`` if the advertisement data should be allowed or ``False`` + if the advertisement data should be filtered out. + """ # Backends will make best effort to filter out advertisements that # don't match the service UUIDs, but if other apps are scanning at the # same time or something like that, we may still receive advertisements # that don't match. So we need to do more filtering here to get the # expected behavior. - if self._service_uuids: - if not advertisement_data.service_uuids: - return + if not self._service_uuids: + # if there is no filter, everything is allowed + return True + + if not service_uuids: + # if there is a filter the advertisement data doesn't contain any + # service UUIDs, filter it out + return False - for uuid in advertisement_data.service_uuids: - if uuid in self._service_uuids: - break - else: - # if there were no matching service uuids, the don't call the callback - return + for uuid in service_uuids: + if uuid in self._service_uuids: + # match was found, keep this advertisement + return True + + # there were no matching service uuids, filter this one out + return False + + def call_detection_callbacks( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """ + Calls all registered detection callbacks. + + Backend implementations should call this method when an advertisement + event is received from the OS. + """ for callback in self._ad_callbacks.values(): callback(device, advertisement_data) diff --git a/bleak/backends/winrt/characteristic.py b/bleak/backends/winrt/characteristic.py index f72e9c67..6a576bf6 100644 --- a/bleak/backends/winrt/characteristic.py +++ b/bleak/backends/winrt/characteristic.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import sys -from typing import List, Union +from typing import Callable, List, Union from uuid import UUID if sys.version_info >= (3, 12): @@ -68,7 +68,11 @@ class BleakGATTCharacteristicWinRT(BleakGATTCharacteristic): """GATT Characteristic implementation for the .NET backend, implemented with WinRT""" - def __init__(self, obj: GattCharacteristic, max_write_without_response_size: int): + def __init__( + self, + obj: GattCharacteristic, + max_write_without_response_size: Callable[[], int], + ): super().__init__(obj, max_write_without_response_size) self.__descriptors = [] self.__props = [ diff --git a/bleak/backends/winrt/client.py b/bleak/backends/winrt/client.py index 518fa0fe..a04ec7c5 100644 --- a/bleak/backends/winrt/client.py +++ b/bleak/backends/winrt/client.py @@ -218,7 +218,8 @@ def __init__( # Backend specific. WinRT objects. if isinstance(address_or_ble_device, BLEDevice): - self._device_info = address_or_ble_device.details.adv.bluetooth_address + data = address_or_ble_device.details + self._device_info = (data.adv or data.scan).bluetooth_address else: self._device_info = None self._requested_services = ( @@ -293,7 +294,8 @@ async def connect(self, **kwargs) -> bool: self.address, f"Device with address {self.address} was not found." ) - self._device_info = device.details.adv.bluetooth_address + data = device.details + self._device_info = (data.adv or data.scan).bluetooth_address logger.debug("Connecting to BLE device @ %s", self.address) @@ -381,8 +383,6 @@ def session_status_changed_event_handler( ) loop.call_soon_threadsafe(handle_session_status_changed, args) - pdu_size_event = asyncio.Event() - def max_pdu_size_changed_handler(sender: GattSession, args): try: max_pdu_size = sender.max_pdu_size @@ -393,7 +393,6 @@ def max_pdu_size_changed_handler(sender: GattSession, args): return logger.debug("max_pdu_size_changed_handler: %d", max_pdu_size) - pdu_size_event.set() # Start a GATT Session to connect event = asyncio.Event() @@ -490,29 +489,6 @@ def max_pdu_size_changed_handler(sender: GattSession, args): cache_mode=cache_mode, ) - # There is a race condition where the max_pdu_size_changed event - # might not be received before the get_services() call completes. - # We could put this wait before getting services, but that would - # make the connection time longer. So we put it here instead and - # fix up the characteristics if necessary. - if not pdu_size_event.is_set(): - try: - # REVISIT: Devices that don't support > default PDU size - # may be punished by this timeout with a slow connection - # time. We may want to consider an option to ignore this - # timeout for such devices. - async with async_timeout(1): - await pdu_size_event.wait() - except asyncio.TimeoutError: - logger.debug( - "max_pdu_size_changed event not received, using default" - ) - - for char in self.services.characteristics.values(): - char._max_write_without_response_size = ( - self._session.max_pdu_size - 3 - ) - # a connection may not be made until we request info from the # device, so we have to get services before the GATT session # is set to active @@ -789,10 +765,10 @@ def dispose_on_cancel(future): f"Could not get GATT descriptors for characteristic {characteristic.uuid} ({characteristic.attribute_handle})", ) - # NB: max_pdu_size might not be valid at this time so we - # start with default size and will update later new_services.add_characteristic( - BleakGATTCharacteristicWinRT(characteristic, 20) + BleakGATTCharacteristicWinRT( + characteristic, lambda: self._session.max_pdu_size - 3 + ) ) for descriptor in descriptors: diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 651fbc75..723ae1fe 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -50,7 +50,7 @@ class _RawAdvData(NamedTuple): advertising data like other platforms, so se have to do it ourselves. """ - adv: BluetoothLEAdvertisementReceivedEventArgs + adv: Optional[BluetoothLEAdvertisementReceivedEventArgs] """ The advertisement data received from the BluetoothLEAdvertisementWatcher.Received event. """ @@ -188,6 +188,9 @@ def _received_handler( data = bytes(section.data) service_data[str(UUID(bytes=bytes(data[15::-1])))] = data[16:] + if not self.is_allowed_uuid(uuids): + return + # Use the BLEDevice to populate all the fields for the advertisement data to return advertisement_data = AdvertisementData( local_name=local_name, @@ -219,7 +222,7 @@ async def start(self) -> None: # Callbacks for WinRT async methods will never happen in STA mode if # there is nothing pumping a Windows message loop. - assert_mta() + await assert_mta() # start with fresh list of discovered devices self.seen_devices = {} diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index 50fac389..89a18c2d 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -1,9 +1,24 @@ +import asyncio import ctypes +import sys +from ctypes import wintypes from enum import IntEnum from typing import Tuple from ...exc import BleakError +if sys.version_info < (3, 11): + from async_timeout import timeout as async_timeout +else: + from asyncio import timeout as async_timeout + + +def _check_result(result, func, args): + if not result: + raise ctypes.WinError() + + return args + def _check_hresult(result, func, args): if result: @@ -12,6 +27,26 @@ def _check_hresult(result, func, args): return args +# not defined in wintypes +_UINT_PTR = wintypes.WPARAM + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc +_TIMERPROC = ctypes.WINFUNCTYPE( + None, wintypes.HWND, _UINT_PTR, wintypes.UINT, wintypes.DWORD +) + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-settimer +_SetTimer = ctypes.windll.user32.SetTimer +_SetTimer.restype = _UINT_PTR +_SetTimer.argtypes = [wintypes.HWND, _UINT_PTR, wintypes.UINT, _TIMERPROC] +_SetTimer.errcheck = _check_result + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-killtimer +_KillTimer = ctypes.windll.user32.KillTimer +_KillTimer.restype = wintypes.BOOL +_KillTimer.argtypes = [wintypes.HWND, wintypes.UINT] + + # https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cogetapartmenttype _CoGetApartmentType = ctypes.windll.ole32.CoGetApartmentType _CoGetApartmentType.restype = ctypes.c_int @@ -60,28 +95,71 @@ def _get_apartment_type() -> Tuple[_AptType, _AptQualifierType]: return _AptType(api_type.value), _AptQualifierType(api_type_qualifier.value) -def assert_mta() -> None: +async def assert_mta() -> None: """ Asserts that the current apartment type is MTA. Raises: - BleakError: If the current apartment type is not MTA. + BleakError: + If the current apartment type is not MTA and there is no Windows + message loop running. .. versionadded:: 0.22 + + .. versionchanged:: 0.22.2 + + Function is now async and will not raise if the current apartment type + is STA and the Windows message loop is running. """ if hasattr(allow_sta, "_allowed"): return try: apt_type, _ = _get_apartment_type() - if apt_type != _AptType.MTA: - raise BleakError( - f"The current thread apartment type is not MTA: {apt_type.name}. Beware of packages like pywin32 that may change the apartment type implicitly." - ) except OSError as e: # All is OK if not initialized yet. WinRT will initialize it. - if e.winerror != _CO_E_NOTINITIALIZED: - raise + if e.winerror == _CO_E_NOTINITIALIZED: + return + + raise + + if apt_type == _AptType.MTA: + # if we get here, WinRT probably set the apartment type to MTA and all + # is well, we don't need to check again + setattr(allow_sta, "_allowed", True) + return + + event = asyncio.Event() + + def wait_event(*_): + event.set() + + # have to keep a reference to the callback or it will be garbage collected + # before it is called + callback = _TIMERPROC(wait_event) + + # set a timer to see if we get a callback to ensure the windows event loop + # is running + timer = _SetTimer(None, 1, 0, callback) + + try: + async with async_timeout(0.5): + await event.wait() + except asyncio.TimeoutError: + raise BleakError( + "Thread is configured for Windows GUI but callbacks are not working." + + ( + " Suspect unwanted side effects from importing 'pythoncom'." + if "pythoncom" in sys.modules + else "" + ) + ) + else: + # if the windows event loop is running, we assume it is going to keep + # running and we don't need to check again + setattr(allow_sta, "_allowed", True) + finally: + _KillTimer(None, timer) def allow_sta(): @@ -115,7 +193,12 @@ def uninitialize_sta(): .. versionadded:: 0.22 """ + try: - assert_mta() - except BleakError: + _get_apartment_type() + except OSError as e: + # All is OK if not initialized yet. WinRT will initialize it. + if e.winerror == _CO_E_NOTINITIALIZED: + return + else: ctypes.windll.ole32.CoUninitialize() diff --git a/bleak/uuids.py b/bleak/uuids.py index ed92698c..2730e506 100644 --- a/bleak/uuids.py +++ b/bleak/uuids.py @@ -1214,15 +1214,15 @@ def normalize_uuid_str(uuid: str) -> str: # 16-bit uuid1 = normalize_uuid_str("1234") - # uuid1 == "00001234-1000-8000-00805f9b34fb" + # uuid1 == "00001234-0000-1000-8000-00805f9b34fb" # 32-bit uuid2 = normalize_uuid_str("12345678") - # uuid2 == "12345678-1000-8000-00805f9b34fb" + # uuid2 == "12345678-0000-1000-8000-00805f9b34fb" # 128-bit - uuid3 = normalize_uuid_str("12345678-1234-1234-1234567890ABC") - # uuid3 == "12345678-1234-1234-1234567890abc" + uuid3 = normalize_uuid_str("12345678-0000-1234-1234-1234567890ABC") + # uuid3 == "12345678-0000-1234-1234-1234567890abc" .. versionadded:: 0.20 .. versionchanged:: 0.21 @@ -1245,12 +1245,12 @@ def normalize_uuid_16(uuid: int) -> str: Normaizes a 16-bit integer UUID to the format used by Bleak. Returns: - 128-bit UUID as string with the format ``"0000xxxx-1000-8000-00805f9b34fb"``. + 128-bit UUID as string with the format ``"0000xxxx-0000-1000-8000-00805f9b34fb"``. Example:: uuid = normalize_uuid_16(0x1234) - # uuid == "00001234-1000-8000-00805f9b34fb" + # uuid == "00001234-0000-1000-8000-00805f9b34fb" .. versionadded:: 0.21 """ @@ -1262,12 +1262,12 @@ def normalize_uuid_32(uuid: int) -> str: Normaizes a 32-bit integer UUID to the format used by Bleak. Returns: - 128-bit UUID as string with the format ``"xxxxxxxx-1000-8000-00805f9b34fb"``. + 128-bit UUID as string with the format ``"xxxxxxxx-0000-1000-8000-00805f9b34fb"``. Example:: uuid = normalize_uuid_32(0x12345678) - # uuid == "12345678-1000-8000-00805f9b34fb" + # uuid == "12345678-0000-1000-8000-00805f9b34fb" .. versionadded:: 0.21 """ diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 514eac05..5a2ec28d 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -8,6 +8,109 @@ When things don't seem to be working right, here are some things to try. Common Mistakes --------------- +Calling ``asyncio.run()`` more than once +======================================== + +Bleak requires the same asyncio run loop to be used for all of its operations. +And it requires the loop to always be running because there are background tasks +that need to always be running. Therefore, make sure you only call ``asyncio.run()`` +once at the start of your program. **Your program will not work correctly if you +call it more than once.** Even if it seems like it is working, crashes and other +problems will occur eventually. + +DON'T! + +.. code-block:: python + + async def scan(): + return await BleakScanner.find_device_by_name("My Device") + + async def connect(device): + async with BleakClient(device) as client: + data = await client.read_gatt_char(MY_CHAR_UUID) + print("received:" data) + + # Do not wrap each function call in asyncio.run() like this! + device = asyncio.run(scan()) + if not device: + print("Device not found") + else: + asyncio.run(connect(device)) + + +DO! + +.. code-block:: python + + async def scan(): + return await BleakScanner.find_device_by_name("My Device") + + async def connect(device): + async with BleakClient(device) as client: + data = await client.read_gatt_char(MY_CHAR_UUID) + print("received:" data) + + # Do have one async main function that does everything. + async def main(): + device = await scan() + if not device: + print("Device not found") + return + + await connect(device) + + asyncio.run(main()) + + +DON'T! + +.. code-block:: python + + async def scan_and_connect(): + device = await BleakScanner.find_device_by_name("My Device") + if not device: + print("Device not found") + return + + async with BleakClient(device) as client: + data = await client.read_gatt_char(MY_CHAR_UUID) + print("received:" data) + + + while True: + # Don't call asyncio.run() multiple times like this! + asyncio.run(scan_and_connect()) + # Never use blocking sleep in an asyncio programs! + time.sleep(5) + + +DO! + +.. code-block:: python + + async def scan_and_connect(): + device = await BleakScanner.find_device_by_name("My Device") + if not device: + print("Device not found") + return + + async with BleakClient(device) as client: + data = await client.read_gatt_char(MY_CHAR_UUID) + print("received:" data) + + # Do have one async main function that does everything. + async def main(): + while True: + await scan_and_connect() + # Do use asyncio.sleep() in an asyncio program. + await asyncio.sleep(5) + + asyncio.run(main()) + + +Naming your script ``bleak.py`` +=============================== + Many people name their first script ``bleak.py``. This causes the script to crash with an ``ImportError`` similar to:: @@ -74,15 +177,32 @@ Not working when threading model is STA Packages like ``pywin32`` and it's subsidiaries have an unfortunate side effect of initializing the threading model to Single Threaded Apartment (STA) when -imported. This causes async WinRT functions to never complete. because there -isn't a message loop running. Bleak needs to run in a Multi Threaded Apartment -(MTA) instead (this happens automatically on the first WinRT call). +imported. This causes async WinRT functions to never complete if Bleak is being +used in a console application (no Windows graphical user interface). This is +because there isn't a Windows message loop running to handle async callbacks. +Bleak, when used in a console application, needs to run in a Multi Threaded +Apartment (MTA) instead (this happens automatically on the first WinRT call). Bleak should detect this and raise an exception with a message similar to:: - The current thread apartment type is not MTA: STA. + Thread is configured for Windows GUI but callbacks are not working. + +You can tell a ``pywin32`` package caused the issue by checking for +``"pythoncom" in sys.modules``. If it is there, then likely it triggered the +problem. You can avoid this by setting ``sys.coinit_flags = 0`` before importing +any package that indirectly imports ``pythoncom``. This will cause ``pythoncom`` +to use the default threading model (MTA) instead of STA. + +Example:: + + import sys + sys.coinit_flags = 0 # 0 means MTA + + import win32com # or any other package that causes the issue -To work around this, you can use one of the utility functions provided by Bleak. + +If the issue was caused by something other than the ``pythoncom`` module, there +are a couple of other helper functions you can try. If your program has a graphical user interface and the UI framework *and* it is properly integrated with asyncio *and* Bleak is not running on a background @@ -98,14 +218,20 @@ thread then call ``allow_sta()`` before calling any other Bleak APis:: # can safely ignore pass -The more typical case, though, is that some library has imported something like -``pywin32`` which breaks Bleak. In this case, you can uninitialize the threading -model like this:: +The more typical case, though, is that some library has imported something similar +to ``pythoncom`` with the same unwanted side effect of initializing the main +thread of a console application to STA. In this case, you can uninitialize the +threading model like this:: + + import naughty_module # this sets current thread to STA :-( - import win32com # this sets current thread to STA :-( - from bleak.backends.winrt.util import uninitialize_sta + try: + from bleak.backends.winrt.util import uninitialize_sta - uninitialize_sta() # undo the unwanted side effect + uninitialize_sta() # undo the unwanted side effect + except ImportError: + # not Windows, so no problem + pass -------------- diff --git a/examples/discover.py b/examples/discover.py index c16f5bd1..6e8e99f5 100644 --- a/examples/discover.py +++ b/examples/discover.py @@ -18,7 +18,9 @@ async def main(args: argparse.Namespace): print("scanning for 5 seconds, please wait...") devices = await BleakScanner.discover( - return_adv=True, cb=dict(use_bdaddr=args.macos_use_bdaddr) + return_adv=True, + service_uuids=args.services, + cb=dict(use_bdaddr=args.macos_use_bdaddr), ) for d, a in devices.values(): @@ -31,6 +33,13 @@ async def main(args: argparse.Namespace): if __name__ == "__main__": parser = argparse.ArgumentParser() + parser.add_argument( + "--services", + metavar="", + nargs="*", + help="UUIDs of one or more services to filter for", + ) + parser.add_argument( "--macos-use-bdaddr", action="store_true", diff --git a/poetry.lock b/poetry.lock index c3278b39..71369f90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,23 +22,6 @@ files = [ {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] -[[package]] -name = "attrs" -version = "22.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.5" -files = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] - -[package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] - [[package]] name = "Babel" version = "2.10.3" @@ -288,6 +271,20 @@ files = [ {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] +[[package]] +name = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "flake8" version = "5.0.4" @@ -497,30 +494,19 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "1.0.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] - [[package]] name = "pycodestyle" version = "2.9.1" @@ -630,43 +616,43 @@ pyobjc-core = ">=10.0" [[package]] name = "pytest" -version = "7.1.3" +version = "8.2.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, - {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, + {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, + {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +pluggy = ">=1.5,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.19.0" +version = "0.23.7" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"}, - {file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"}, + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, ] [package.dependencies] -pytest = ">=6.1.0" +pytest = ">=7.0.0,<9" [package.extras] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" @@ -699,13 +685,13 @@ files = [ [[package]] name = "requests" -version = "2.31.0" +version = "2.32.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, + {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, ] [package.dependencies] @@ -950,7 +936,6 @@ files = [ {file = "winrt_Windows.Devices.Bluetooth-2.0.1-cp39-cp39-win32.whl", hash = "sha256:dffff7e6801b8e69e694b36fe1d147094fb6ac29ce54fd3ca3e52ab417473cc4"}, {file = "winrt_Windows.Devices.Bluetooth-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:62bae806ecdf3021e1ec685d5a44012657c0961ca2027eeb1c37864f53577e51"}, {file = "winrt_Windows.Devices.Bluetooth-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:7f3b102e9b4bea1915cc922b571e0c226956c161102d228ec1788e3caf4e226d"}, - {file = "winrt_windows_devices_bluetooth-2.0.1.tar.gz", hash = "sha256:c91b3f54bfe1ed7e1e597566b83a625d32efe397b21473668046ccb4b57f5a28"}, ] [package.dependencies] @@ -978,7 +963,6 @@ files = [ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.1-cp39-cp39-win32.whl", hash = "sha256:86d11fd5c055f76eefac7f6cc02450832811503b83280e26a83613afe1d17c92"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:c8495ce12fda8fce3da130664917eb199d19ca1ebf7d5ab996f5df584b5e3a1f"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:0e91160a98e5b0fffae196982b5670e678ac919a6e14eb7e9798fdcbff45f8d2"}, - {file = "winrt_windows_devices_bluetooth_advertisement-2.0.1.tar.gz", hash = "sha256:130e6238a1897bfef98a711cdb1b02694fa0e18eb67d8fd4019a64a53685b331"}, ] [package.dependencies] @@ -1006,7 +990,6 @@ files = [ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.1-cp39-cp39-win32.whl", hash = "sha256:3e2a54db384dcf05265a855a2548e2abd9b7726c8ec4b9ad06059606c5d90409"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:2bdbb55d4bef15c762a5d5b4e27b534146ec6580075ed9cc681e75e6ff0d5a97"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:01e74c76d4f16b4490d78c8c7509f2570c843366c1c6bf196a5b729520a31258"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-2.0.1.tar.gz", hash = "sha256:69d7dabd53fbf9acdc2d206def60f5c9777416a9d6911c3420be700aaff4e492"}, ] [package.dependencies] @@ -1034,7 +1017,6 @@ files = [ {file = "winrt_Windows.Devices.Enumeration-2.0.1-cp39-cp39-win32.whl", hash = "sha256:9301f5e00bd2562b063e0f6e0de6f0596b7fb3eabc443bd7e115772de6cc08f9"}, {file = "winrt_Windows.Devices.Enumeration-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:9999d93ae9441d35c564d498bb4d6767b593254a92b7c1559058a7450a0c304e"}, {file = "winrt_Windows.Devices.Enumeration-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:504ca45a9b90387a2f4f727dbbeefcf79beb013ac7a29081bb14c8ab13e10367"}, - {file = "winrt_windows_devices_enumeration-2.0.1.tar.gz", hash = "sha256:ed227dd22ece253db913de24e4fc5194d9f3272e2a5959a2450ae79e81bf7949"}, ] [package.dependencies] @@ -1062,7 +1044,6 @@ files = [ {file = "winrt_Windows.Foundation-2.0.1-cp39-cp39-win32.whl", hash = "sha256:7abbf10666d6da5dbfb6a47125786a05dac267731a3d38feb8faddade9bf1151"}, {file = "winrt_Windows.Foundation-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:aab18ad12de63a353ab1847aff3216ba4e5499e328da5edcb72c8007da6bdb02"}, {file = "winrt_Windows.Foundation-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:bde9ecfc1c75410d669ee3124a84ba101d5a8ab1911807ad227658624fc22ffb"}, - {file = "winrt_windows_foundation-2.0.1.tar.gz", hash = "sha256:6e4da10cff652ac17740753c38ebe69565f5f970f60100106469b2e004ef312c"}, ] [package.dependencies] @@ -1090,7 +1071,6 @@ files = [ {file = "winrt_Windows.Foundation.Collections-2.0.1-cp39-cp39-win32.whl", hash = "sha256:c26ab7b3342669dc09be62db5c5434e7194fb6eb1ec5b03fba1163f6b3e7b843"}, {file = "winrt_Windows.Foundation.Collections-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:2f9bc7e28f3ade1c1f3113939dbf630bfef5e3c3018c039a404d7e4d39aae4cb"}, {file = "winrt_Windows.Foundation.Collections-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:1f3e76f3298bec3938d94e4857c29af9776ec78112bdd09bb7794f06fd38bb13"}, - {file = "winrt_windows_foundation_collections-2.0.1.tar.gz", hash = "sha256:7d18955f161ba27d785c8fe2ef340f338b6edd2c5226fe2b005840e2a855e708"}, ] [package.dependencies] @@ -1118,7 +1098,6 @@ files = [ {file = "winrt_Windows.Storage.Streams-2.0.1-cp39-cp39-win32.whl", hash = "sha256:f6dec418ad0118c258a1b2999fc8d4fc0d9575e6353a75a242ff8cc63c9b2146"}, {file = "winrt_Windows.Storage.Streams-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:9fbc40f600ab44a45cda47b698bd8e494e80e221446a5958c4d8d59a8d46f117"}, {file = "winrt_Windows.Storage.Streams-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:08059774c6d49d195ce00c3802d19364f418a6f3e42b94373621551792d2da60"}, - {file = "winrt_windows_storage_streams-2.0.1.tar.gz", hash = "sha256:3de8351ed3a9cfcfd1d028ce97ffe90bb95744f906eef025b06e7f4431943ee6"}, ] [package.dependencies] @@ -1145,4 +1124,4 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.13" -content-hash = "65f1bbdea293cf8fef2b3531e7f28b9aff5b62df6c9bbf4a290d9cdfe5f8884d" +content-hash = "1c5a0ca2a13af74c3aecab397053176e19054a14bcbb031d89c8413acaf8a468" diff --git a/pyproject.toml b/pyproject.toml index cf027c0e..092f7ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bleak" -version = "0.22.1" +version = "0.22.2" description = "Bluetooth Low Energy platform Agnostic Klient" authors = ["Henrik Blidh "] license = "MIT" @@ -49,8 +49,8 @@ flake8 = "^5.0.0" isort = "^5.13.2" [tool.poetry.group.test.dependencies] -pytest = "^7.0.0" -pytest-asyncio = "^0.19.0" +pytest = "^8.2.1" +pytest-asyncio = "^0.23.7" pytest-cov = "^3.0.0 " [build-system] diff --git a/tests/bleak/backends/winrt/test_utils.py b/tests/bleak/backends/winrt/test_utils.py new file mode 100644 index 00000000..3c90dbcc --- /dev/null +++ b/tests/bleak/backends/winrt/test_utils.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python + +"""Tests for `bleak.backends.winrt.util` package.""" + +import sys + +import pytest + +if not sys.platform.startswith("win"): + pytest.skip("skipping windows-only tests", allow_module_level=True) + +from ctypes import windll, wintypes + +from bleak.backends.winrt.util import ( + _check_hresult, + allow_sta, + assert_mta, + uninitialize_sta, +) +from bleak.exc import BleakError + +# https://learn.microsoft.com/en-us/windows/win32/api/objbase/ne-objbase-coinit +COINIT_MULTITHREADED = 0x0 +COINIT_APARTMENTTHREADED = 0x2 + +# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex +_CoInitializeEx = windll.ole32.CoInitializeEx +_CoInitializeEx.restype = wintypes.LONG +_CoInitializeEx.argtypes = [wintypes.LPVOID, wintypes.DWORD] +_CoInitializeEx.errcheck = _check_hresult + +# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-couninitialize +_CoUninitialize = windll.ole32.CoUninitialize +_CoUninitialize.restype = None +_CoUninitialize.argtypes = [] + + +@pytest.fixture(autouse=True) +def run_around_tests(): + # reset global state + try: + delattr(allow_sta, "_allowed") + except AttributeError: + pass + + yield + + +@pytest.mark.asyncio +async def test_assert_mta_no_init(): + """Test device_path_from_characteristic_path.""" + + await assert_mta() + + +@pytest.mark.asyncio +async def test_assert_mta_init_mta(): + """Test device_path_from_characteristic_path.""" + + _CoInitializeEx(None, COINIT_MULTITHREADED) + + try: + await assert_mta() + assert hasattr(allow_sta, "_allowed") + finally: + _CoUninitialize() + + +@pytest.mark.asyncio +async def test_assert_mta_init_sta(): + """Test device_path_from_characteristic_path.""" + + _CoInitializeEx(None, COINIT_APARTMENTTHREADED) + + try: + with pytest.raises( + BleakError, + match="Thread is configured for Windows GUI but callbacks are not working.", + ): + await assert_mta() + finally: + _CoUninitialize() + + +@pytest.mark.asyncio +async def test_uninitialize_sta(): + """Test device_path_from_characteristic_path.""" + + _CoInitializeEx(None, COINIT_APARTMENTTHREADED) + uninitialize_sta() + + await assert_mta()