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.