Skip to content

bleak: raise BleakBluetoothNotAvailableError from BleakAdapter.get() on Linux and Windows - #1986

Open
Vodur wants to merge 1 commit into
hbldh:developfrom
rudokir:bleak-adapter-state
Open

bleak: raise BleakBluetoothNotAvailableError from BleakAdapter.get() on Linux and Windows#1986
Vodur wants to merge 1 commit into
hbldh:developfrom
rudokir:bleak-adapter-state

Conversation

@Vodur

@Vodur Vodur commented May 11, 2026

Copy link
Copy Markdown

I would prefer to just consider making raising of BleakBluetoothNotAvailableError from BleakAdapter.get() consistent on all platforms first.

BleakAdapter.get() now raises BleakBluetoothNotAvailableError on all platforms when the adapter is not powered on. CoreBluetooth already did this via wait_until_ready(); this adds the same behavior to the BlueZ and WinRT backends:

  • BlueZ — checks that the adapter is present in the manager's cached properties and that Powered is set; raises with reason NO_BLUETOOTH or POWERED_OFF.
  • WinRT — fetches the Bluetooth radio via Radio.get_radios_async() and checks RadioState; raises with reason NO_BLUETOOTH or POWERED_OFF.

Testing

  • BlueZ raise path is covered by an integration test (test_get_raises_when_powered_off) that power-cycles the adapter via bluetoothctl in the vhci VM.
  • The WinRT raise path has no coverage, since there is no Windows integration runner in CI — this is the remaining codecov/patch gap.

The state property and subscribe_state_changes() work (native event sources, no polling) is deferred to a follow-up PR.

Refs #320, #1060.

Notes:

  • I quoted dlech's exact words so it's unambiguous we're responding to his prioritization, not paraphrasing.
  • The testing section pre-explains the codecov red so he reads it as intentional, not an oversight — folded into the description rather than a separate comment, since you said one-by-one and this keeps it self-contained.
  • "deferred to a follow-up PR" sets up the bleak-adapter-state-fullscope work without committing you to open it now.

@Vodur Vodur changed the title Bleak adapter state Add BleakAdapter.state to check local adapter state May 11, 2026
@codecov

codecov Bot commented May 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.00000% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.40%. Comparing base (ae2f589) to head (eca7a80).

Files with missing lines Patch % Lines
bleak/backends/winrt/adapter.py 16.66% 10 Missing ⚠️
bleak/backends/bluezdbus/adapter.py 30.00% 6 Missing and 1 partial ⚠️
bleak/backends/winrt/scanner.py 66.66% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1986      +/-   ##
===========================================
- Coverage    52.45%   52.40%   -0.05%     
===========================================
  Files           43       43              
  Lines         4097     4110      +13     
  Branches       504      508       +4     
===========================================
+ Hits          2149     2154       +5     
- Misses        1817     1826       +9     
+ Partials       131      130       -1     
Flag Coverage Δ
bluez-integration-py310 39.17% <12.00%> (+0.02%) ⬆️
bluez-integration-py311 39.17% <12.00%> (+0.02%) ⬆️
bluez-integration-py312 39.17% <12.00%> (+0.02%) ⬆️
bluez-integration-py313 39.17% <12.00%> (+0.02%) ⬆️
bluez-integration-py314 37.67% <12.00%> (+0.02%) ⬆️
macos-latest-py310 19.97% <0.00%> (-0.07%) ⬇️
macos-latest-py311 19.97% <0.00%> (-0.07%) ⬇️
macos-latest-py312 19.97% <0.00%> (-0.07%) ⬇️
macos-latest-py313 19.97% <0.00%> (-0.07%) ⬇️
macos-latest-py314 19.78% <0.00%> (-0.07%) ⬇️
ubuntu-latest-py310 23.91% <8.00%> (-0.03%) ⬇️
ubuntu-latest-py311 23.91% <8.00%> (-0.03%) ⬇️
ubuntu-latest-py312 23.91% <8.00%> (-0.03%) ⬇️
ubuntu-latest-py313 23.91% <8.00%> (-0.03%) ⬇️
ubuntu-latest-py314 22.03% <8.00%> (-0.03%) ⬇️
windows-latest-py310 18.68% <16.00%> (-0.04%) ⬇️
windows-latest-py311 18.68% <16.00%> (-0.04%) ⬇️
windows-latest-py312 18.68% <16.00%> (-0.04%) ⬇️
windows-latest-py313 18.68% <16.00%> (-0.04%) ⬇️
windows-latest-py314 18.41% <16.00%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@Vodur
Vodur force-pushed the bleak-adapter-state branch 4 times, most recently from 1c9c6da to 37603c4 Compare May 11, 2026 09:06
@dlech

dlech commented May 11, 2026

Copy link
Copy Markdown
Collaborator

I've been hoping to move Bleak to using structured concurrency.

If we add a property that can be read, then people are just going to poll it in a loop with a sleep, which is not good program design. So I would prefer to leave out the state property.

And callbacks aren't good program design for async programming either. They were just the only thing available when Bleak was written. So I would prefer to avoid that too.

Structured concurrency might look something like this:

async def main():
    while True:
        try:
            async with BleakAdapter.get():
                # if Bluetooth adapter state changes inside of this
                # context, it will cancel the task
                await do_stuff_with_bluetooth()
        except BleakBluetoothNotAvailableError as ex:
            try_again = await inform_user_of_reason_and_and_ask(ex)
            if try_again:
                continue

        break

I think this is going to take more though and trying out different things to figure out if this can actually work as intended. I consider it lower priority that other things we have open right now.

@dlech

dlech commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Windows has no native event

It looks like it does.

https://learn.microsoft.com/en-us/uwp/api/windows.devices.radios.radio.statechanged?view=winrt-28000

@Vodur

Vodur commented May 11, 2026

Copy link
Copy Markdown
Author

Thanks - structured concurrency does make sense, and Radio.StateChanged exists, so all three platforms have a real event source we can hang the cancellation off:

Platform Native event
Linux D-Bus PropertiesChanged on org.bluez.Adapter1
macOS CentralManagerDelegate.centralManagerDidUpdateState_ (already wired)
Windows Radio.StateChanged

Building on your sketch, this is roughly how the user side could look:

 from contextlib import asynccontextmanager

 async def main():
     while True:
         try:
             # async ctx mgr: raises immediately if not POWERED_ON;
             # cancels the body if state changes mid-context.
             async with BleakAdapter.acquire() as adapter:
                 await do_stuff_with_bluetooth(adapter)
         except BleakBluetoothNotAvailableError as ex:
             if not await inform_user_and_retry(ex):
                 break

 Implementation-side it'd be something like (rough sketch):

 @asynccontextmanager
 async def acquire(cls, *, bluez: BlueZAdapterArgs = {}):
     backend = await PlatformBleakAdapter.get(bluez=bluez)
     if backend.state != AdapterState.POWERED_ON:
         raise BleakBluetoothNotAvailableError(...)

     async with asyncio.TaskGroup() as tg:
         async def _watch() -> None:
             async for state in backend.state_events():  # platform-native source
                 if state != AdapterState.POWERED_ON:
                     raise BleakBluetoothNotAvailableError(...)
         tg.create_task(_watch())
         try:
             yield cls(backend)
         finally:
             tg.cancel()

If you'd rather close this and revisit later, happy to. If the sketch above is roughly the direction you had in mind, I can take a stab at it as a fresh PR (using state_events() async-iterators per backend and the native StateChanged on Windows).

@dlech

dlech commented May 11, 2026

Copy link
Copy Markdown
Collaborator

backend = await PlatformBleakAdapter.get(bluez=bluez)
if backend.state != AdapterState.POWERED_ON:
raise BleakBluetoothNotAvailableError(...)

Actually, PlatformBleakAdapter.get() should already be raising BleakBluetoothNotAvailableError if we can't get a powered on adapter. It is probably only implemented in CoreBluetooth right now though.

@dlech

dlech commented May 11, 2026

Copy link
Copy Markdown
Collaborator

As for the async context manager implementation and async iterator, this can be really tricky to get right. I've been considering using anyio to help with this as it has solved a lot of these hard-to-get-right things already.

And we can't use TaskGroup in the implementation until we drop support for Python 3.10.

@Vodur

Vodur commented May 11, 2026

Copy link
Copy Markdown
Author

Splitting the work: this PR ships building blocks only - state property, get() raising BleakBluetoothNotAvailableError when not powered on (your CoreBluetooth wait_until_ready point, now matched on Linux/Windows), and subscribe_state_changes(callback) as an async with-scoped subscription using native events on each platform: Linux PropertiesChanged, macOS delegate, Windows Radio.StateChanged (which you were right exists - I was wrong about that earlier, sorry).

The cancel-on-state-change wrapper goes in a follow-up where the anyio decision lives. Whether it will be anyio or dropping support for Python 3.10.

Ok to push?

@dlech

dlech commented May 11, 2026

Copy link
Copy Markdown
Collaborator

I would prefer to just consider making raising of BleakBluetoothNotAvailableError from BleakAdapter.get() consistent on all platforms first.

All other proposed changes here are lower priority for me.

@Vodur
Vodur force-pushed the bleak-adapter-state branch 2 times, most recently from ec95c93 to 7fc5f3e Compare May 11, 2026 17:10
@Vodur Vodur changed the title Add BleakAdapter.state to check local adapter state bleak: add BleakAdapter.state to check local adapter state May 21, 2026
@Vodur
Vodur force-pushed the bleak-adapter-state branch 5 times, most recently from f31734e to dd25b6e Compare May 30, 2026 16:38
@Vodur Vodur changed the title bleak: add BleakAdapter.state to check local adapter state bleak: raise BleakBluetoothNotAvailableError from BleakAdapter.get() on Linux and Windows Jun 1, 2026
@Vodur
Vodur force-pushed the bleak-adapter-state branch from dd25b6e to 1341a68 Compare June 1, 2026 17:53
@Vodur

Vodur commented Jun 3, 2026

Copy link
Copy Markdown
Author

Only codecov patch is failing here. The WinRT raise paths in BleakAdapter.get() have no coverage since there's no Windows integration runner, and I dropped the WinRT unit tests in favor of integration. The BlueZ path is covered by an integration test.

Fine to leave the WinRT paths uncovered, or would you rather I add a Windows integration job?

@dlech dlech left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is hard to test since it requires very conditions on the OS. So I am OK to leave figuring out some good tests for later.

Comment thread CHANGELOG.rst Outdated

Changed
-------
* Changed ``BleakAdapter.get()`` to raise ``BleakBluetoothNotAvailableError`` on Linux and Windows when the local Bluetooth adapter is not powered on, matching the existing CoreBluetooth behaviour.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There has still not been a release with BleakAdapter, so changelog for anything changing BleakAdapter does not make sense. The Added section already covers adding this new class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

Comment thread bleak/backends/winrt/adapter.py Outdated
Comment on lines +37 to +42
radio = None
for candidate in await Radio.get_radios_async():
if candidate.kind == RadioKind.BLUETOOTH:
radio = candidate
break
if radio is None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
radio = None
for candidate in await Radio.get_radios_async():
if candidate.kind == RadioKind.BLUETOOTH:
radio = candidate
break
if radio is None:
for radio in await Radio.get_radios_async():
if radio.kind == RadioKind.BLUETOOTH:
break
else:

Can be a bit simpler this way.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

radio loop is gone, get() now uses BluetoothAdapter.get_default_async() instead.

Comment thread bleak/backends/winrt/adapter.py
adapter_path = (
f"/org/bluez/{adapter}" if adapter else manager.get_default_adapter()
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be better to just call BlueZManager.get_default_adapter() from here so that we don't have to duplicate the logic.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

…on Linux and Windows

CoreBluetooth already raises BleakBluetoothNotAvailableError from
BleakAdapter.get() via wait_until_ready() when Bluetooth is not powered
on. This makes the BlueZ and WinRT backends do the same, so callers can
rely on the exception across all platforms.

- BlueZ: checks for adapter presence in the manager's cached properties
  and the Powered property; raises NO_BLUETOOTH or POWERED_OFF.
- WinRT: fetches the Bluetooth radio via Radio.get_radios_async() and
  checks RadioState; raises NO_BLUETOOTH or POWERED_OFF.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants