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()