Decoding Bluetooth Low Energy advertisements with Python, Bleak and Construct
Many Bluetooth Low Energy (BLE) devices broadcast data using manufacturer-specific data or service data in their advertisements. The data format is often defined in a specification or should be reverse-engineered.
If you want to decode the binary data format into usable chunks of data from various data types in your own Python program, I find the Construct library quite an accessible solution. And Bleak is my favorite BLE library in Python, so first install Bleak and Construct:
As an example, let's see how you could decode iBeacon advertisements with Bleak and Construct in Python.
The iBeacon specification
The iBeacon specification, published by Apple, is officially called Proximity Beacon. The idea is to have Bluetooth beacons advertise their presence in order to calculate their approximate distance. You can find the iBeacon specification online.
The specification lists the format of the iBeacon advertising packet. This always consists of two advertising data structures: flags (of length 2) and manufacturer-specific data (of length 26). That’s why an iBeacon advertising packet is always 30 bytes long (1 + 2 + 1 + 26). Here's the structure of the complete packet:
We're specifically interested in the second data structure with type 0xff, which signifies that it's manufacturer-specific data. The first two bytes of these manufacturer-specific data are always the company ID. To know which company ID is linked to which company, consult the list of all registered company identifiers. Normally the company ID is the ID of the manufacturer of the device. However, Apple allows other manufacturers to use its company ID for iBeacon devices if they agree to the license.
If you want to know more about the meaning of the iBeacon packet's fields, consult the document Getting Started with iBeacon published by Apple.
Decoding iBeacon advertisements
Now that you know the format, let's see how to scan for iBeacon advertisements and decode them:
ibeacon_scanner/ibeacon_scanner.py (Source)
"""Scan for iBeacons. Copyright (c) 2022 Koen Vervloesem SPDX-License-Identifier: MIT """ import asyncio from uuid import UUID from construct import Array, Byte, Const, Int8sl, Int16ub, Struct from construct.core import ConstError from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData ibeacon_format = Struct( "type_length" / Const(b"\x02\x15"), "uuid" / Array(16, Byte), "major" / Int16ub, "minor" / Int16ub, "power" / Int8sl, ) def device_found( device: BLEDevice, advertisement_data: AdvertisementData ): """Decode iBeacon.""" try: apple_data = advertisement_data.manufacturer_data[0x004C] ibeacon = ibeacon_format.parse(apple_data) uuid = UUID(bytes=bytes(ibeacon.uuid)) print(f"UUID : {uuid}") print(f"Major : {ibeacon.major}") print(f"Minor : {ibeacon.minor}") print(f"TX power : {ibeacon.power} dBm") print(f"RSSI : {device.rssi} dBm") print(47 * "-") except KeyError: # Apple company ID (0x004c) not found pass except ConstError: # No iBeacon (type 0x02 and length 0x15) pass async def main(): """Scan for devices.""" scanner = BleakScanner() scanner.register_detection_callback(device_found) while True: await scanner.start() await asyncio.sleep(1.0) await scanner.stop() asyncio.run(main())
First it defines a Struct
object from the Construct library, and calls it ibeacon_format
. A Struct
is a collection of ordered and usually named fields. [1] Each field in itself is an instance of a Construct class. This is how you define the data type of bytes in an iBeacon data structure. In this case the fields are:
Const(b"\x02\x15")
: a constant value of two bytes, because these are always fixed for an iBeacon data structure.Array(16, Byte)
: an array of 16 bytes that define the UUID.Int16ub
for both the major and minor numbers, which are both unsigned big-endian 16-bit integers.Int8sl
for the measured power, which is a signed 8-bit integer.
Now when the device_found
function receives manufacturer-specific data from Apple, it can easily parse it. It just calls the parse
function on the ibeacon_format
object, with the bytes of the manufacturer-specific data as its argument. The result is an object of the class construct.lib.containers.Container
, with the fields that are defined in the ibeacon_format
struct. That's why you can just refer to the fields like ibeacon.major
, ibeacon.minor
and ibeacon.power
.
However, ibeacon.uuid
returns a construct.lib.containers.ListContainer
object, which is printed as a list of separate numbers. To print it like a UUID, first convert it to bytes and then create a UUID
object from these bytes.
If you run this program, it will scan continuously for iBeacons and shows their information:
$ python3 ibeacon_scanner.py UUID : fda50693-a4e2-4fb1-afcf-c6eb07647825 Major : 1 Minor : 2 TX power : -40 dBm RSSI : -80 dBm ----------------------------------------------- UUID : d1338ace-002d-44af-88d1-e57c12484966 Major : 1 Minor : 39904 TX power : -59 dBm RSSI : -98 dBm -----------------------------------------------
This will keep scanning indefinitely. Just press Ctrl+c to stop the program.