How to Make a Detailed Bluetooth LE Scanner with Python

Proto Bioengineering
9 min readFeb 24, 2023

--

Find a device’s services, characteristics, signal strength, and more.

Photo by Silivan Munguarakarama on Unsplash

This tutorial builds upon our basic article on making a simple Bluetooth LE scanner with Mac and Python. If you’re new to working with Bluetooth and Python, check out that article first.

However, if you know Bluetooth LE and Python already, we’ll get right to it.

This article is Mac-oriented, but the code works for Windows and Linux distros as well.

What We Cover

  • How to find every Bluetooth device near you
  • How to find out what every Bluetooth device is capable of
  • The difference between Bluetooth “services” and “characteristics”
  • How Bluetooth devices make themselves discoverable

Requirements

  • Mac OS 12.x+ (optionally, Windows or Linux)
  • Python 3
  • Bleak 0.18.1 (a Python Bluetooth LE library)
  • Bluetooth LE devices within 100 meters of you

Bluetooth vs. Bluetooth LE

This tutorial is for Bluetooth LE devices, which are not the same as regular Bluetooth devices (“Bluetooth Classic”). For the rest of this article, “Bluetooth” will mean “Bluetooth LE.”

How to Install Bleak

Install Bleak with:

pip install bleak

Or:

python -m pip install bleak

If you’re not familiar with Pip, it is the leading Python package manager, and allows you to download 1000s of popular Python packages with one line. Most Python tutorials around the Internet use Pip.

If you need help troubleshooting Pip on Linux, read here.

A Basic Bluetooth Scanner

In the last scanner article, we made a Bluetooth Scanner using Bleak, a popular Bluetooth LE library for Python.

# scanner.py

import asyncio
from bleak import BleakScanner

async def main():
devices = await BleakScanner.discover()
for device in devices:
print(device)

asyncio.run(main())

This scanner printed out the address and name of every Bluetooth LE device near us. (On Windows and Linux, the addresses below will be shorter MAC addresses instead of long UUIDs.)

Now, we’re going to use this basic data to probe into these devices a little more and find out what they’re capable of.

What Else We Can Scan For

Bluetooth devices are always sending out information about themselves. The reason we can turn on Bluetooth on our Mac and see a bunch of available devices is because those devices are shouting, “Hey, I’m here and I want to connect!” at all times. Our computer is simply listening in on the broadcast, like a radio.

What we can do with our scanner is listen to these “Hey!” messages as the first part. Then we can use this data to connect to each device and ask them more questions about themselves.

The end result will look like this:

A list of local Bluetooth LE devices found by our scanner.

And this:

Some details about the capabilities of devices found by our Bluetooth LE scanner.

The Two Improvements We’re Going For

We are going to:

  1. Improve the detail of advertisement data that we get, including signal strength.
  2. Get the “services,” “characteristics,” and “descriptors,” AKA things that tell us what the device is capable of and how to interact with it.
  3. Bonus: Add color to make the output easier to read

In the end, we’ll have a scanner that tells us the full capabilities of every Bluetooth LE device (at least, devices not already connected to other things) in our area.

About Services, Characteristics, and Descriptors

Services, characteristics, and descriptors are just fancy words for the features of a Bluetooth device.

For example, a fitness tracking watch may have the following:

  • a Heart Rate Service
  • a Movement Service
  • a Battery Service

And so on. Each service also has multiple characteristics. For example, a Battery Service could have a characteristic for how much battery percentage is left and a separate characteristic about whether the battery is charging or not.

Each characteristic also has descriptors, which is just helpful info for developers that describes a characteristic. Descriptors don’t always exist on a device.

Overall, services are collections of similar characteristics, and descriptors help us understand the characteristics better.

Coding a More Detailed Scanner: Raw Data and Signal Strength

To get more detail from our scanner, we’re going to use the same code as above, except when we get to the print part, we’re going to call all the attributes from device and print them. These attributes are called:

  • address
  • details
  • name
  • metadata
  • rssi

We can see them if we add print(dir(device)) (line 9 below) to our print loop in scanner.py. This is the Bluetooth device’s “advertisement data,” the stuff that it advertises to the world when it’s looking to connect.

Note: Bleak 0.19 now comes with a BleakScanner.discovered_devices() function to make printing advertisement data easier. This tutorial uses Bleak 0.18.1.

Output of scanner.py on the left. Scanner.py on the right.

On the left above, we see the following get printed a few times:

[‘__class__’, ‘__delattr__’, ‘__dict__’, ‘__dir__’, ‘__doc__’, ‘__eq__’,
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__','__subclasshook__', '__weakref__', 'address', 'details', 'metadata',
'name', 'rssi']

At the end are the attributes of device that we can access and print.

To use these, let’s add some print statements and formatting. This will be the new for device in devices: loop in scanner.py:

for device in devices:
print(device.name)
print(device.address)
print(device.details)
print(device.metadata)
print(device.rssi)

This will print all of the device data (in an ugly way):

On the left, is our new output that prints the following for every device:

  • Device Name
  • Address (a UUID like ABCDEFG1–2345-XXXX-XXXX-XXXXXXXXXX)
  • Technical details about the device
  • Metadata, which includes UUIDs for some services and manufacturer data
  • RSSI (signal strength, a number like -87 or -92 )

Let’s make it more user-friendly with some formatting:

# scanner.py

import asyncio
from bleak import BleakScanner

async def main():
devices = await BleakScanner.discover()
for device in devices:
print()
print(f"Name: {device.name}")
print(f"Address: {device.address}")
print(f"Details: {device.details}")
print(f"Metadata: {device.metadata}")
print(f"RSSI: {device.rssi}")

asyncio.run(main())

This gives us:

We can see that the first device found was named [AV] Samsung Soundbar MS650, along with its address, some other details, and a signal strength of -94 (which is pretty weak).

Even More Detail: Services and Characteristics

Let’s ask the devices what they’re capable of, meaning asking them what their services, characteristics, and descriptors are.

To do this we’ll connect to them briefly with BleakClient.

We’ll make a separate for device in devices: loop to go back through the list of found devices and connect to them. Otherwise, doing both the advertisement collection piece and device connection piece at the same time will cause device connection to time out.

Our second for loop will look like:

for device in devices:
this_device = await BleakScanner.find_device_by_address(device.address)
try:
async with BleakClient(this_device) as client:
...
# Where we'll print services and characteristics for each device
except:
print("Could not connect to device: " + device)

This for loop will fit into the code like this:

# scanner.py

import asyncio
from bleak import BleakScanner

async def main():
devices = await BleakScanner.discover()
for device in devices:
print()
print(f"Name: {device.name}")
print(f"Address: {device.address}")
print(f"Details: {device.details}")
print(f"Metadata: {device.metadata}")
print(f"RSSI: {device.rssi}")

for device in devices:
try:
this_device = await BleakScanner.find_device_by_address(device.address)
async with BleakClient(this_device) as client:
...
# Where we'll print services and characteristics for each device
except:
print("Could not connect to device: " + device)

asyncio.run(main())

Note: We’ve wrapped the code in a try...except statement, so that if one device doesn’t connect, it doesn’t crash our whole program.

The services, characteristics, and descriptors are all nested under the client variable after we’ve connected (via async with BleakClient(this_device) as client:).

  • Services can be accessed with client.services
  • Characteristics are under each service (example:client.services[0].characteristics)
  • Descriptors are under each characteristic (example:client.services[0].characteristics[0].descriptors)

Services have many characteristics, which have many descriptors.

Under our asynchronous BleakClient(this_device) as client: line, we’ll add the following to print each device’s services and so on:

async with BleakClient(this_device) as client:
print(f'Services found for device')
print(f'\tDevice address:{device.address}')
print(f'\tDevice name:{device.name}')

print('\tServices:')
for service in client.services:
print()
print(f'\t\tDescription: {service.description}')
print(f'\t\tService: {service}')

print('\t\tCharacteristics:')
for c in service.characteristics:
print()
print(f'\t\t\tUUID: {c.uuid}'),
print(f'\t\t\tDescription: {c.uuid}')
print(f'\t\t\tHandle: {c.uuid}'),
print(f'\t\t\tProperties: {c.uuid}')

print('\t\tDescriptors:')
for descrip in c.descriptors:
print(descrip)

Note: We use f-strings (f”Service: {service}”) to format our output. They allow the use of {variables} directly inside of strings.

The Full Code

# Scan for nearby Bluetooth LE devices and their services

import asyncio
from bleak import BleakClient, BleakScanner

async def main():
devices = await BleakScanner.discover()
for device in devices:
print()
print(f"Name: {device.name}")
print(f"Address: {device.address}")
print(f"Details: {device.details}")
print(f"Metadata: {device.metadata}")
print(f"RSSI: {device.rssi}")

for device in devices:
try:
this_device = await BleakScanner.find_device_by_address(device.address, timeout=20)
async with BleakClient(this_device) as client:
print(f'Services found for device')
print(f'\tDevice address:{device.address}')
print(f'\tDevice name:{device.name}')

print('\tServices:')
for service in client.services:
print()
print(f'\t\tDescription: {service.description}')
print(f'\t\tService: {service}')

print('\t\tCharacteristics:')
for c in service.characteristics:
print()
print(f'\t\t\tUUID: {c.uuid}'),
print(f'\t\t\tDescription: {c.uuid}')
print(f'\t\t\tHandle: {c.uuid}'),
print(f'\t\t\tProperties: {c.uuid}')

print('\t\tDescriptors:')
for descrip in c.descriptors:
print(f'\t\t\t{descrip}')

except Exception as e:
print(f"Could not connect to device with info: {device}")
print(f"Error: {e}")

asyncio.run(main())

A small slice of the output:

Above, we see that an Apple TV was found and that it offers one Bluetooth service, Apple Continuity Service, which is what helps Apple devices seamlessly share data with each other. This service has 1 unnamed characteristic as well as 2 descriptors (the lines starting with 000029xx-0000-etc...).

Further down the line, we see a Samsung TV that has 2 unnamed characteristics with 1 descriptor each:

Those are the final results of our Bluetooth LE scanner. We can find all Bluetooth LE devices within 20–100 meters of us and learn all about their capabilities.

Bonus: Adding Color to the Output

One last thing we can add to make our scanner easy on the eyes is color.

One way to do that in Python and Terminal is to sandwich each of our printed strings between some weird characters that look like this:

\033[92m <-- this means green

The easiest way to do this is:

print("\033[92m" + "This text will be green" + "\033[0m")

The \033[0m at the end tells Terminal to turn the text back to white.

To test this, open Terminal, type python (or python3), then copy and paste the print statement above. Your text will be green.

Note: If you accidentally turn your entire Terminal green, try typing print(“\033[0m”) to reset it. “\033[0m” is the END character for colorization in Bash/Terminal.

To add color to our scanner, we can put these characters (“\033[92m” and the like) in our print strings.

To turn the device names green, we can change the name printing line from this:

print(f"Name: {device.name}")

To this:

print(f"Name: \033[92m{device.name}\033[0m")

Now, device names will stick out more in our output:

Below is a list of color codes, you can use. Make sure you use the END character (\033[0m) at the end of your strings, so that you don’t turn everything the wrong color.

# Bash output color codes

GREEN = '\033[92m'
RED = '\033[91m'
CYAN = '\033[96m'
YELLOW = '\033[93m'
GOLD = '\033[33m'
BOLD = '\033[1m'
END = '\033[0m'

Troubleshooting

If you’re having trouble with any part of the scanner, try the following:

  • Make sure both your Mac’s Bluetooth and your Bluetooth LE device are on (sometimes Bluetooth fails silently)
  • Set the timeout argument for BleakScanner.discover() to a number greater than 5.0 (the default). Example: BleakScanner.discover(timeout=10.0)
  • Set the timeout argument for BleakScanner.find_device_by_address() to a number greater than 10.0 (the default). Example: BleakScanner.find_device_by_address(address, timeout=20.0)
  • Check out Bleak’s own Troubleshooting docs.

Read More in Our eBook

We now have an e-book about how to control and stream real-time data from Bluetooth LE devices. It currently has a price of “Pay What You Want” on Ko-fi.

Questions and Feedback

If you have questions or comments, email us at protobioengineering@gmail.com or message us on Instagram (@protobioengineering).

If you liked this article, consider supporting us by donating a coffee.

--

--

Proto Bioengineering

Learn to code for science. “Everything simple is false. Everything complex is unusable.” — Paul Valery