Skip to content

Platforms

The API is the same on Linux, macOS and Windows. This page covers the few places where the platforms still differ.

macOS

Permission

macOS asks the user before a program can use Bluetooth, and remembers the answer under the program's name.

A bundled application is asked about under its own name. Put NSBluetoothAlwaysUsageDescription in the application's Info.plist, with the reason to show in the dialog. Without it, macOS can end the library's helper process instead of asking, and the open_bluetooth() block raises aio_rfcomm.macos.MissingUsageDescriptionError.

A script run from a terminal has no name of its own. Without one, macOS goes by the permission of the program that started it, such as the terminal. It asks under that program's name. If that program cannot be asked, the open_bluetooth() block raises MissingUsageDescriptionError. If that program already has permission, Bluetooth works without asking. To give it permission, add it under Privacy & Security > Bluetooth in System Settings.

A script started by launchd, with a Python from python.org, Homebrew or uv, is asked about as that Python itself. Measured on macOS 26.6.2.

Give the script a name before opening Bluetooth, so that it is asked about under its own name:

import aio_rfcomm.macos

aio_rfcomm.macos.prompt_under_name(name="my program", reason="to talk to the robot")

The user sees both strings in the permission dialog. The call does nothing on Linux and Windows, so it can run on every platform.

Keep the name the same

macOS remembers the answer under the name you pass. A name it has not seen before is asked about again. A name used before gets the answer given then.

While the dialog is open, the availability is AWAITING_PERMISSION. When the user answers, it changes to USABLE or NOT_ALLOWED.

If the user says no, the availability is NOT_ALLOWED. After the user allows it in the system settings, start the program again.

Continuous integration

GitHub's macOS runners have a virtual Bluetooth controller that never works, so Bluetooth cannot be used there. Which availability you see depends on prompt_under_name:

Availability Why
Without prompt_under_name UNSUPPORTED The runner already has Bluetooth permission, and the script uses it. macOS then reports that Bluetooth is unsupported.
With prompt_under_name AWAITING_PERMISSION The script asks under its own name. Nobody answers the dialog, and macOS says nothing more until someone does.

Measured on macOS 15.7.9 and 26.6.2 runners.

Bundled applications

On macOS the library uses Bluetooth from a helper process. A bundled application has to include the helper's code.

PyInstaller needs nothing extra. The package tells PyInstaller what to include.

py2app needs the package in its packages option:

setup(
    app=["main.py"],
    options={"py2app": {"packages": ["aio_rfcomm"]}},
)

Without it, the helper is left out, and open_bluetooth fails.

Both were checked on macOS 26.6.2 with PyInstaller 6.22.3 and py2app 0.28.10.

One channel per device at a time

macOS cannot keep two channels open to the same device. It may close the other channel instead of the one you asked for, and the data on both is lost. So opening a second channel to a device that already has one open raises UnsupportedOperationError. Close the first one before opening the next. Channels to different devices at the same time work.

Each channel is served once

On macOS, a channel can be served only once until Bluetooth is turned off and on again. After serving ends, macOS keeps the service published, and a new service on the same channel never receives connections. So the library serves only on channels that have not been used since Bluetooth was turned on, and asking for a used one raises ChannelInUseError.

A program that keeps serving is not affected, and accepts connection after connection. A program that serves, stops and serves again gets a new channel each time. Devices that find the service by its UUID follow it to the new channel. A device set up with a fixed channel number does not.

If serve runs out of channels, or connections to every service on this Mac wait until they time out, turn Bluetooth off and on again.

When a device's services change

macOS keeps a stored copy of each device's services, and reads them again only when it connects to the device. While the device stays connected, the copy can be out of date. Most devices never change their services, so this rarely matters. It matters when a device's services change, for example when the device runs an aio-rfcomm server that restarts on a new channel. Then open_service can raise ServiceNotFoundError even though the service is there. The error then says the copy may be out of date.

To refresh it, disconnect the device, then open the service again. The library connects to the device, and macOS reads its services as it does. From a Linux device, give the Mac's address:

bluetoothctl disconnect F8:4D:89:62:C2:5D

A server that always serves on the same channel avoids the problem. Seen on macOS 26.6.2, with a peer running BlueZ 5.85.

Linux

Permission

The library talks to BlueZ over the system D-Bus, so the program needs permission to send messages to org.bluez. BlueZ's standard D-Bus policy gives that to every user. A sandboxed program, such as a Snap or a Flatpak, has to be granted it.

Bluetooth sockets

Two things need a Python built with Bluetooth socket support: serving without naming a channel, and connecting by channel number. uv's own Python builds do not have it. The Python packaged by most Linux distributions does. To check yours, run python -c "import socket; socket.AF_BLUETOOTH", which fails without it. Without it, these raise UnsupportedOperationError. Connecting by service UUID works on every Python.

When you serve on a channel number of your choosing without that support, the library cannot check that the channel is free. If it is not, BlueZ publishes nothing and gives no error. Channel 9, for example, is often taken by Object Push.

When a device's services change

BlueZ keeps a stored copy of each device's services, and does not always refresh it. Most devices never change their services, so this rarely matters. It matters when a device's services change after pairing, for example when the device is running an aio-rfcomm server of its own. Then open_service can raise ServiceNotFoundError even though the service is there.

To refresh it, connect the device, and then try again:

bluetoothctl connect 00:1A:7D:DA:71:0C

Windows

When a device's services change

open_service always asks the device, so it finds a service that has moved to another channel. list_known_devices with a service uses Windows' stored copy of each device's services instead. It can leave out a service the device has added since, or list one it has removed. Each open_service refreshes the stored copy for that device.

Next: Troubleshooting, for when something goes wrong.