From 73d2cb58f06d198925e7699d7112298ef9a62fab Mon Sep 17 00:00:00 2001 From: kaz399 Date: Sun, 12 May 2024 23:16:20 +0900 Subject: [PATCH 01/10] backends/winrt: add address acquisition process when advertising data is None (#1571) In some situations, device.details has the scan response data only and advertising data is None. Since the ble address cannot be obtained from device.details.adv this time, the ble address is obtained from the device.details.scan (scan response data). --- CHANGELOG.rst | 5 +++++ bleak/backends/winrt/client.py | 6 ++++-- bleak/backends/winrt/scanner.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 77f18e30..415049ae 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,11 @@ and this project adheres to `Semantic Versioning 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) diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 651fbc75..dc7f6575 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. """ From e2a8bd7a552f74babcac96bb58cabdc545119594 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Wed, 15 May 2024 15:58:34 -0500 Subject: [PATCH 02/10] docs/troubleshooting: section on asynio.run() It is quite common for people to report "bugs" in Bleak because they are using it wrong. Now we can have a link to give them to explain this in detail. --- docs/troubleshooting.rst | 103 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 514eac05..7e5c3cfc 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:: From 1d9f382b3f224e136fbe4fcd22c5358b8774f6ea Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Fri, 17 May 2024 20:42:17 +0200 Subject: [PATCH 03/10] Update uuids.py --- bleak/uuids.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bleak/uuids.py b/bleak/uuids.py index ed92698c..b80af2ff 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 @@ -1250,7 +1250,7 @@ def normalize_uuid_16(uuid: int) -> str: Example:: uuid = normalize_uuid_16(0x1234) - # uuid == "00001234-1000-8000-00805f9b34fb" + # uuid == "00001234-0000-1000-8000-00805f9b34fb" .. versionadded:: 0.21 """ From d3b1097e7e3ce9c0665198e952b2f1fcdf55bbab Mon Sep 17 00:00:00 2001 From: Patrick <14628713+patman15@users.noreply.github.com> Date: Sat, 18 May 2024 17:02:22 +0200 Subject: [PATCH 04/10] Fix UUID doc (#1574) --- bleak/uuids.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bleak/uuids.py b/bleak/uuids.py index b80af2ff..2730e506 100644 --- a/bleak/uuids.py +++ b/bleak/uuids.py @@ -1245,7 +1245,7 @@ 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:: @@ -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 """ From b8149a5b5504eaacbd45ee56c1339fa4395e1345 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 05:34:12 +0000 Subject: [PATCH 05/10] --- updated-dependencies: - dependency-name: requests dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index c3278b39..1baa7f78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -699,13 +699,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] From 75cd7797912a66419e7e9fdf897a0c95c5bd3cf6 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 21 May 2024 18:08:55 -0500 Subject: [PATCH 06/10] backends: filter discovered devices This is a follow-up to a818521 ("backends/scanner: always filter by service_uuids") to also filter discovered devices by the service UUIDs. This was overlooked in that change and on Windows actually caused a regression. Fixes: https://github.com/hbldh/bleak/issues/1576 --- CHANGELOG.rst | 5 +++ bleak/backends/bluezdbus/scanner.py | 5 ++- bleak/backends/corebluetooth/scanner.py | 11 ++++-- bleak/backends/p4android/scanner.py | 3 ++ bleak/backends/scanner.py | 51 +++++++++++++++++-------- bleak/backends/winrt/scanner.py | 3 ++ examples/discover.py | 11 +++++- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 415049ae..b811c574 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,11 @@ Changed * Retrieve the BLE address required by ``BleakClientWinRT`` from scan response if advertising is None (WinRT). * Changed type hint for ``adv`` attribute of ``bleak.backends.winrt.scanner._RawAdvData``. +Fixed +----- +* Fixed ``discovered_devices_and_advertisement_data`` returning devices that should + be filtered out by service UUIDs. Fixes #1576. + `0.22.1`_ (2024-05-07) ====================== diff --git a/bleak/backends/bluezdbus/scanner.py b/bleak/backends/bluezdbus/scanner.py index d325ce92..186e7d42 100644 --- a/bleak/backends/bluezdbus/scanner.py +++ b/bleak/backends/bluezdbus/scanner.py @@ -237,6 +237,10 @@ def _handle_advertising_data(self, path: str, props: Device1) -> 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/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/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/scanner.py b/bleak/backends/winrt/scanner.py index dc7f6575..13bfe0d3 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -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, 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", From d45ec90d3cca4113da7c118cac753f441c46cca2 Mon Sep 17 00:00:00 2001 From: vabt-igd <90448279+vabt-igd@users.noreply.github.com> Date: Fri, 24 May 2024 00:32:16 +0200 Subject: [PATCH 07/10] Android: fix 'Descriptor None was not found!' (#1580) The keys in the descriptor dict are the normalized 128-bit UUID string, so we need to use this to correctly find the descriptor. Fixes #823 --- CHANGELOG.rst | 1 + bleak/backends/p4android/defs.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b811c574..fb25f57f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,6 +19,7 @@ Fixed ----- * Fixed ``discovered_devices_and_advertisement_data`` returning devices that should be filtered out by service UUIDs. Fixes #1576. +* Fixed a ``Descriptor None was not found!`` exception occurring in ``start_notify()`` on Android. Fixes #823. `0.22.1`_ (2024-05-07) ====================== 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) From bd8f0225c1957742d422e57533cb3b6193119399 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 1 Jun 2024 11:14:33 -0500 Subject: [PATCH 08/10] backends/winrt: don't throw exception for properly configured GUI apps (#1581) In commit 4a653e6 ("backends/winrt: raise exception when trying to scan with STA") we added a check to raise an exception when trying to scan when PyWinRT set the apartment model to STA. However, properly working GUI apps will have the apartment model set to STA but Bleak will still work because there is something pumping the Windows message loop. We don't want to raise an exception in this case to avoid breaking working apps. We can improve the test by checking if the current thread is actually pumping the message loop by scheduling a callback via a the win32 SetTimeout function. If the callback is called, then we know that the message loop is being pumped. If not, then we probably are not going to get async callbacks from the WinRT APIs and we raise an exception in this case. --- CHANGELOG.rst | 1 + bleak/backends/winrt/scanner.py | 2 +- bleak/backends/winrt/util.py | 103 ++++++++++++++++++++--- docs/troubleshooting.rst | 45 +++++++--- poetry.lock | 89 ++++++++------------ pyproject.toml | 4 +- tests/bleak/backends/winrt/test_utils.py | 92 ++++++++++++++++++++ 7 files changed, 257 insertions(+), 79 deletions(-) create mode 100644 tests/bleak/backends/winrt/test_utils.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fb25f57f..3050b0e7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,7 @@ Fixed * Fixed ``discovered_devices_and_advertisement_data`` returning devices that should be filtered out by service UUIDs. Fixes #1576. * Fixed a ``Descriptor None was not found!`` exception occurring in ``start_notify()`` on Android. Fixes #823. +* Fixed exception raised when starting ``BleakScanner`` while running in a Windows GUI app. `0.22.1`_ (2024-05-07) ====================== diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 13bfe0d3..723ae1fe 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -222,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..503b7178 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:: unreleased + + 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/docs/troubleshooting.rst b/docs/troubleshooting.rst index 7e5c3cfc..5a2ec28d 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -177,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. -To work around this, you can use one of the utility functions provided by Bleak. +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 + + +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 @@ -201,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 win32com # this sets current thread to STA :-( - from bleak.backends.winrt.util import uninitialize_sta + import naughty_module # this sets current thread to STA :-( - uninitialize_sta() # undo the unwanted side effect + try: + from bleak.backends.winrt.util import uninitialize_sta + + uninitialize_sta() # undo the unwanted side effect + except ImportError: + # not Windows, so no problem + pass -------------- diff --git a/poetry.lock b/poetry.lock index 1baa7f78..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" @@ -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..c4e87525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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() From 425abb38f9e09c4d662195ff6ff529abd9cb4b68 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 1 Jun 2024 15:21:07 -0500 Subject: [PATCH 09/10] backends/characteristic: make max_write_without_response_size dynamic (#1586) It has been observed that the max MTU exchange may not be complete before the connection is established, at least on Windows. This reverts the previous attempt to work around this on Windows and instead makes the max_write_without_response_size dynamic. This way users can implement a workaround if needed but users who don't need it won't be punished with a longer connection time. The timeout in the previous workaround was also too short for some devices so it wasn't complexly fixing the issue. --- CHANGELOG.rst | 3 +- bleak/backends/bluezdbus/characteristic.py | 4 +-- bleak/backends/bluezdbus/manager.py | 2 +- bleak/backends/characteristic.py | 24 ++++++++++++-- .../backends/corebluetooth/characteristic.py | 6 ++-- bleak/backends/corebluetooth/client.py | 2 +- bleak/backends/p4android/characteristic.py | 4 +-- bleak/backends/p4android/client.py | 2 +- bleak/backends/winrt/characteristic.py | 8 +++-- bleak/backends/winrt/client.py | 32 ++----------------- 10 files changed, 43 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3050b0e7..5861d9ad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,7 @@ Changed ------- * Retrieve the BLE address required by ``BleakClientWinRT`` from scan response if advertising is None (WinRT). * Changed type hint for ``adv`` attribute of ``bleak.backends.winrt.scanner._RawAdvData``. +* ``BleakGATTCharacteristic.max_write_without_response_size`` is now dynamic. Fixed ----- @@ -56,7 +57,7 @@ Fixed * Fixed scanning silently failing on Windows when Bluetooth is off. Fixes #1535. * Fixed using wrong value for ``tx_power`` in Android backend. Fixes #1532. * Fixed 4-character UUIDs not working on ``BleakClient.*_gatt_char`` methods. Fixes #1498. -* Fixed race condition with getting max PDU size on Windows. Fixes #1497. +* Fixed race condition with getting max PDU size on Windows. Fixes #1497. [REVERTED in unreleased] * Fixed filtering advertisement data by service UUID when multiple apps are scanning. Fixes #1534. `0.21.1`_ (2023-09-08) diff --git a/bleak/backends/bluezdbus/characteristic.py b/bleak/backends/bluezdbus/characteristic.py index 3b41147b..ab707827 100644 --- a/bleak/backends/bluezdbus/characteristic.py +++ b/bleak/backends/bluezdbus/characteristic.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import Callable, List, Union from uuid import UUID from ..characteristic import BleakGATTCharacteristic @@ -36,7 +36,7 @@ def __init__( object_path: str, service_uuid: str, service_handle: int, - max_write_without_response_size: int, + max_write_without_response_size: Callable[[], int], ): super(BleakGATTCharacteristicBlueZDBus, self).__init__( obj, max_write_without_response_size diff --git a/bleak/backends/bluezdbus/manager.py b/bleak/backends/bluezdbus/manager.py index 3953fc18..1d5d16b5 100644 --- a/bleak/backends/bluezdbus/manager.py +++ b/bleak/backends/bluezdbus/manager.py @@ -696,7 +696,7 @@ async def get_services( service.handle, # "MTU" property was added in BlueZ 5.62, otherwise fall # back to minimum MTU according to Bluetooth spec. - char_props.get("MTU", 23) - 3, + lambda: char_props.get("MTU", 23) - 3, ) services.add_characteristic(char) 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/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/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 76aed574..a04ec7c5 100644 --- a/bleak/backends/winrt/client.py +++ b/bleak/backends/winrt/client.py @@ -383,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 @@ -395,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() @@ -492,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 @@ -791,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: From c746071a3fcc3b5e69db6d6b23445ec3505d7730 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 1 Jun 2024 15:30:46 -0500 Subject: [PATCH 10/10] v0.22.2 --- CHANGELOG.rst | 8 ++++++-- bleak/backends/winrt/util.py | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5861d9ad..c2d9b43c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,9 @@ and this project adheres to `Semantic Versioning None: .. versionadded:: 0.22 - .. versionchanged:: unreleased + .. 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. diff --git a/pyproject.toml b/pyproject.toml index c4e87525..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"