How to Talk to Bluetooth Devices with Python (Part 1: Getting Data)

Proto Bioengineering
11 min readJul 29, 2023

--

Read data from your Bluetooth LE devices with code.

Photo by Sebastian Scholz (Nuki) on Unsplash

We have covered how to find and connect to Bluetooth LE devices with Python. Now, we will actually do something with this connection by asking our device about itself. We’ll start with a simple example: seeing what percentage the battery is at and whether it is charging or not.

If you’re brand new to Bluetooth programming in Python, check out our in-depth article about how Bluetooth works.

What “talking” to a Bluetooth device looks like

Most Bluetooth LE devices offer abilities like adjusting volume, reading somebody’s heart rate, tracking movement data, and so on. But how is this done?

Underneath the hood, a Bluetooth device has multiple services, with each service controlling a particular feature of the device, such as a:

  • Battery Service
  • Heart Rate Service
  • Device Information

And so on.

Photo by Harrison Broadbent on Unsplash

When we interact with our Bluetooth device, we’re doing one of 3 things:

  1. writing to a service
  2. reading a service
  3. subscribing to a service’s notifications (i.e. listening for live data)

(Reading and subscribing have different uses, which we’ll cover later.)

Some examples of reading/writing to a service

  • To find out the battery percentage of a device, you can read the Battery Service
  • To turn on the movement tracker of a wearable watch, you can write to the Measurement Service (e.g. writing a “1” to turn it on, and “0” for off)
  • To turn on a blinking light on a device, you can again write to the Configuration Service
  • To listen for live data, you can subscribe to the service that is sending that data out (like a Heart Rate Service)

The exact services and their names depend on the manufacturer, but Bluetooth LE devices all follow similar themes and standards.

In reality, we are reading and writing to a characteristic (which is like a smaller feature within a service), but we’ll refer to everything as a “service” for now.

Long story short

Changing stuff typically requires a write operation.

Getting static data or seeing what state something is in (say, if an LED light is on) requires a read operation.

Getting live data requires a notify operation (basically, subscribing to notification messages which will include the live data).

How to actually do it with code

We will start with a basic example: reading the battery percentage and charging status of a Movella DOT.

Movella DOTs (AKA “Xsens DOTs”) are wearable, watch-sized, Bluetooth LE devices for movement tracking.

Movella DOTs are wireless movement trackers. They have a Battery Service, which we can use Python code to talk to.

They also track movement via accelerometers and gyroscopes, which we can get from the Measurement Service. But to keep things broad enough for all sorts of Bluetooth LE devices, we’ll stick with the Battery Service for this article.

Goals

  1. Connect to a Movella DOT
  2. Read the percentage of the battery
  3. Read whether the battery is being charged or not

We’ll also cover general tips for connecting to Bluetooth articles throughout the article and at the end. The concepts for device communication span all types of Bluetooth LE devices.

Requirements

  • A Mac, Windows, or Linux computer
  • Python 3
  • Bleak (a Python Bluetooth LE library)
  • A smart watch, smart light bulb, or other Bluetooth LE device

Overall Steps

  1. Install Bleak
  2. Find the device with a Bluetooth LE scanner
  3. Find the ID of the Battery Service and its characteristics
  4. Read the battery characteristic to get percentage and charging status
  5. Decode the data we just read from the battery characteristic into real numbers

The decode step seems odd, but it’s just a few lines of code that formats the data that is going to and from the Bluetooth device. Bluetooth data is in binary or hexadecimal format by default.

Step 1: Install Bleak

Bleak is a Bluetooth LE library for Python that works on Windows, Linux, and Mac OS.

To install it, you can use Pip (the Python package manager):

pip install bleak

For installation assistance, see Bleak’s documentation.

Step 2: Find the device with a scanner

Every Bluetooth LE device has an address. To communicate with it, we need to find this address.

Luckily, Bluetooth devices are wirelessly shouting their names and addresses out all the time (AKA “advertisement”). We can use some Python code to write a Bluetooth scanner and find these names and addresses.

Bluetooth devices send out “advertisements” (digitally shouting their existence into the universe).

You can use the code from our article on how to make a Bluetooth scanner in Python. It’s only 10 lines:

# Bluetooth scanner
# Prints the name and address of every nearby Bluetooth LE device

import asyncio
from bleak import BleakScanner

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

asyncio.run(main())

By running this, we’ll get a list of devices within about 100 meters of us. When you run it, make sure your target device is on and has Bluetooth enabled.

Windows and Linux devices will get names and MAC addresses:

Mac OS devices will get names and UUIDs:

MAC addresses and UUIDs serve the same purpose of giving a Bluetooth device a unique ID.

We’re using a Movella DOT for this example. At the Proto lab, we have two Movella DOTs (previously branded as Xsens DOT) and are working on Macs, so we can use the one of the two Xsens DOT UUIDs found above:

  • 338312FA-C3D1–183F-325A-0726AFDBEB78
  • 509808FF-ECFE-895D-C1FE-BE5AC5DB6204

If you happened to be on Windows and using a Movella DOT like us, you’d use the MAC address D0:C2:AD:7D:AD:07 like in the first screenshot above.

Step 3: Find the ID of the Battery Service

This is where things get unique depending on the brand of Bluetooth LE device you’re connecting to.

To read/write to any services that a Bluetooth device has, we need the UUID of those services. To find out the UUID, we need one of two things (ideally both):

  1. Documentation from the manufacturer that lists the UUIDs of all of the services
  2. A more robust Bluetooth scanner that lists out details about a Bluetooth device

A UUID for a service would look something like ABCDEFG1–0000–1111–2222–55556666777, just like the UUID addresses that Mac OS computers happen to use for Bluetooth device addresses.

Reading the documentation to find the UUID

The Movella DOT happens to come with a Bluetooth LE Services Specifications manual, which lists the UUID for the Battery Service and each of its characteristics.

Part of the Battery Service section from Movella DOT’s Bluetooth documentation.

Your device should come with similar documentation and list the UUIDs of every service and characteristic. (Characteristics are like smaller features of a service.)

If your device does not have documentation, we can:

  1. Use a more detailed Bluetooth scanner to find the Battery Service’s UUID
  2. Use the global Bluetooth standard for battery services to find the service through trial and error (the less fun option)

We will cover option 2 (investigative Bluetooth-ing) in a future tutorial.

Scanning the device for its service UUIDs

If you grab the code from the last section of our Detailed Bluetooth Scanner tutorial, you can find every device near you, along with their services and characteristics.

For example, here’s a snippet of said detailed scanner output, which found the Battery Service for a device:

The Terminal output for a Bluetooth scanner, which found an Apple device and its services.

Not every device names its services so nicely. The Movella DOT / Xsens DOT’s services are more obscure with Unknown names:

Output of the detailed Bluetooth scanner.

The descriptor near the bottom of the output does say CygnusBattery, though we have to look for it. Apple did a better job of naming their services by comparison.

Getting the full UUID for Movella DOT’s battery service

We used a combination of the manufacturer’s documentation and the output of our scanner to narrow down the address of the Battery Service characteristic.

The documentation gives us the base UUID for every service on the Movella DOT as well as what number to substitute for the xxxx:

Movella DOT documentation. How to find the UUID for the many services a DOT has.

Thus, any characteristics under the Battery Service would have an address similar to 15173000–4947–11e9–8646-d663bd873d93. We’ll use this kind of address directly in our Python code.

Both the docs and the scanner output also tell us that the Battery Service only has one characteristic, Battery Characteristic, and that it’s address modifier is actually 0x3001.

This means that to read battery data, we will use the exact address 15173001–4947–11e9–8646-d663bd873d93.

And this characteristic has two pieces of data that we can read:

  1. battery level (%)
  2. charging status

More on this in the next step.

Step 4: Read the Battery Characteristic

To get the battery data, we will read the Battery Characteristic.

We have the device address (the UUID, or MAC address for Windows users):

338312FA-C3D1–183F-325A-0726AFDBEB78

And the Battery Characteristic UUID:

15173001–4947–11e9–8646-d663bd873d93

All we have to do is take the basic Bluetooth device connection code from our original Bluetooth connection tutorial and add a read function and a decoding function.

Basic Bluetooth connection template:

# connect.py

import asyncio
from bleak import BleakClient

async def main():
address = "ABCDEFG1-XXXX-XXXX-XXXX-XXXXXXXXXX"

# Connect to the Bluetooth device
async with BleakClient(address) as client:
# Read, write, or do something with the connection
...
print(client.is_connected) # prints True or False

asyncio.run(main())

Adding the characteristic reading function

The function that allows us to read data from our Bluetooth device is Bleak’s read_gatt_char(). (More on the meaning of GATT here.)

The most basic way to do this is to add one line to our Bluetooth connection code:

data = await client.read_gatt_char(...)

Which fits into our code like so:

# Read the Battery Characteristic of a Movella DOT

import asyncio
from bleak import BleakClient

movella_dot_address = "338312FA-C3D1–183F-325A-0726AFDBEB78"
battery_characteristic_uuid = "15173001–4947–11e9–8646-d663bd873d93"

async def main():
# Connect to the Bluetooth device
async with BleakClient(movella_dot_address) as client:
# Read the Battery Characteristic
data = await client.read_gatt_char(battery_characteristic_uuid)
print(data)

asyncio.run(main())

If you’re not yet familiar with async/await, our in-depth article on Python for Bluetooth covers its purpose.

Running the code

If we run the code as-is, we’ll get some data back!

But it looks weird. What is bytearray(b'[\x00')?

That is our device’s battery data, but it is in hexadecimal format. We have to convert it to normal, human-readable format.

Step 5: Decode the battery data

To decode the battery data, we need two things:

  1. a basic understanding of hexadecimal format and/or ASCII characters
  2. our device’s documentation about how the data is encoded

Luckily, the Movella DOT documentation listed how the data was encoded in one of the screenshots from above:

The size column in the second table tells us that each piece of data in the response (bytearray(b'[\x00')) is 1 byte long.

Decoding the bytearray by eye

bytearray(b'[\x00') is a weird string. What exactly does it mean?

The actual data in our response is really the [\x00 part. The rest is just telling us and our computer that it’s formatted in bytes.

What does [\x00 mean then? It should actually look more like \x99\x00 , with each \x signifying the start of a byte. But there’s a weird quirk with hexadecimal, where sometimes computers convert hexadecimal to its ASCII equivalent character. Thus, we get the bracket character [ instead of the number value.

Since the Movella documentation said that each of the 2 pieces of data was one byte, then the [ is literally the ASCII equivalent of that number’s true value. That means that we can look at any ASCII table and see which number [ translates to. In this case, [‘s number equivalent is actually 91. The battery percentage is therefor 91%.

This is just a weird quirk of hexadecimal and how computers format hex strings. We’ll cover how to do this in code next.

Decoding the bytearray with code

To convert the bytes/hex to real numbers, we’ll use NumPy.

The function will take in the bytes, slice them up according to the size of each piece of data, then return a string with readable data:

import numpy as np
...
def encode_bytes_to_string(bytes_):
# Create a template for how to slice up the bytes
data_segments = np.dtype([
('battery_level', np.uint8),
('is_charging', np.uint8)
])
formatted_data = np.frombuffer(bytes_, dtype=data_segments)
return formatted_data

Above, our code tells NumPy that each piece of data is one byte (or 8 bits, np.uint8), and NumPy chops it up and places it into our variables (battery_level and is_charging) accordingly.

If your device’s data is made up of 16-bit integer or 64-bit floats, you could just as easily tell NumPy to group the data that way. NumPy supports many types of numbers and scalars. You can mix and match data types too.

To use the NumPy decoder function, we’ll add it to our code like so:

# Read and decode the Battery Characteristic of a Movella DOT

import asyncio
import numpy as np
from bleak import BleakClient


def encode_bytes_to_string(bytes_):
# Create a template for how to slice up the bytes
data_segments = np.dtype([
('battery_level', np.uint8),
('is_charging', np.uint8)
])
formatted_data = np.frombuffer(bytes_, dtype=data_segments)
return formatted_data

movella_dot_address = "509808FF-ECFE-895D-C1FE-BE5AC5DB6204"
battery_characteristic_uuid = "15173001-4947-11E9-8646-D663BD873D93"

async def main():
# Connect to the Bluetooth device
async with BleakClient(movella_dot_address) as client:
# Read the Battery Characteristic
data = await client.read_gatt_char(battery_characteristic_uuid)
decoded_data = encode_bytes_to_string(data)

# Print the hex and decoded data
print(data)
print(decoded_data)


asyncio.run(main())

The code connects to the dot, reads the Battery Characteristic, decodes the bytes that it read, then prints both the bytes and the decoded data.

Our output looks like:

The 2nd line of output shows the human-readable, decoded string. It tells us that our battery percentage is 88% and that the charging status is 0 (meaning it is not hooked up to a charger).

Photo by Praveen Thirumurugan on Unsplash

Summary for all Bluetooth devices and platforms

The basis of all useful Bluetooth code is that you will connect to the device, then do a read, write or notify operation. Then you will decode and encode data surrounding those operations (before or after reading/writing, etc.).

We covered a basic read operation on an easy subject (the battery) from a MacBook, but these principles apply to all Bluetooth devices when connecting from any operating system.

Coming Soon

More tutorials on how to interact with Bluetooth devices are upcoming. Please stay tuned.

In the meantime, we have a tutorial for how to stream live data from the Movella DOT, which illustrates how to do a notify operation and use the concept of “subscription” for getting real-time data from Bluetooth devices.

Read More in Our eBook

We now have an e-book about how to control and stream real-time data from Bluetooth LE devices. Pay What You Want until June 1st, 2024 on Ko-fi!

Questions and Feedback

If you have questions or feedback, 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