Explore Developer Center's New Chatbot! MongoDB AI Chatbot can be accessed at the top of your navigation to answer all your MongoDB questions.

Introducing MongoDB 8.0, the fastest MongoDB ever!
MongoDB Developer
C++
plus
Sign in to follow topics
MongoDB Developer Centerchevron-right
Developer Topicschevron-right
Languageschevron-right
C++chevron-right

Turn BLE: Implementing BLE Sensors with MCU Devkits

Jorge D. Ortiz-Fuentes13 min read • Published Nov 28, 2023 • Updated Apr 02, 2024
PythonC++
FULL APPLICATION
Facebook Icontwitter iconlinkedin icon
Rate this tutorial
star-empty
star-empty
star-empty
star-empty
star-empty
In the first episode of this series, I shared with you the project that I plan to implement. I went through the initial planning and presented a selection of MCU devkit boards that would be suitable for our purposes.
In this episode, I will try and implement BLE communication on one of the boards. Since the idea is to implement this project as if it were a proof of concept (PoC), once I am moderately successful with one implementation, I will stop there and move forward to the next step, which is implementing the BLE central role in the Raspberry Pi.
Are you ready? Then buckle up for the Bluetooth bumps ahead!

Table of Contents

Concepts

Bluetooth classic vs BLE

Bluetooth is a technology for wireless communications. Although we talk about Bluetooth as if it were a single thing, Bluetooth Classic and Bluetooth Low Energy are mostly different beasts and also incompatible. Bluetooth Classic has a higher transfer rate (up to 3Mb/s) than Bluetooth Low Energy (up to 2Mb/s), but with great transfer rate comes great power consumption (as Spidey's uncle used to say). Bluetooth There are several predefined usages of Bluetooth Classic, abstracted as profiles. Most of them are oriented toward continuously streaming data, such as that used by audio headphones, hands-free calling devices, or even file-transfer applications. Bluetooth Low Energy has a lower latency than its older counterpart and allows for additional network topologies (broadcasting and mesh). These features make it a great fit for IoT and healthcare applications and even beacons or tags where the volume of data is generally lower but users are more sensitive to response time.
In this article, we will be using Bluetooth Low Energy to implement our sensors.

BLE data

In contrast with WiFi and TCP/IP, Bluetooth is much more specific on its expected usages and how the data must be encoded and decoded. This amount of definition makes it easier for devices from different vendors to interoperate for the expected application. However, this also means that the developer must go through the specs, understand the requirements for the intended usage, and have an excellent test suite.
The data exposed by a BLE device is organized into attributes that are the information atom, the building block for this standard. Each attribute has a 16-bit predefined UUID (SIG-Adopted Attributes) or a 128-bit UUID, if it is custom defined. Actually, the 16-bit ones are just a simplified way to refer to 128-bit UUIDs, where the rest of the bits are constant and reserved for the Bluetooth Special Interest Group (SIG).
Within a server, attributes also have an attribute handle. It's a number that identifies that attribute for that type of server. Attributes can also have permissions that define if they can be read, written, notified (pushed), and/or indicated (pushed with acknowledgement) and the required security level for each operation. The Attribute Protocol (ATT) defines how data elements are represented and transferred in BLE. Our noise level measurements will be a standard attribute, with a handle that will depend on where this attribute has been defined with respect to others in the same Bluetooth server. The noise level measurements can be read and notified, but no matter how hard we try, they cannot be written.
The attributes are organized hierarchically and its structure is exposed using the representation defined in the Attribute Protocol. Once the connection is established, the Generic Attribute Profile (GATT) is used exclusively and defines the types of attributes and how they are used to allow two BLE devices to interact with each other. The GATT uses the ATT to describe each of the components of that hierarchy: profiles, services, characteristics, and descriptors. Profiles define all the communication functions of a BLE device as a set of one or more services; they represent possible use cases of the device. Then, services are a type of attribute that define a group of one or more characteristics that are somehow related. At the lowest level of the data hierarchy, characteristics are individual data elements exposed by the server that contain other attributes, like the properties (for example, permissions) or descriptors (for example, conditions for when a characteristic shall be notified).

BLE roles

The Generic Access Profile (GAP) advertises the device and controls connections. It defines the roles of the devices. The GAP sends advertising data either through the Advertising Data Payload or through the Scan Response Payload. The first one is mandatory and it is broadcasted at regular intervals, while the second must be requested by the scanning device.
There are four roles a BLE device can operate in:
  • Broadcaster advertises itself and is not meant to be connected.
  • Observer scans for advertising packets but doesn't get connected.
  • Peripheral advertises itself and listens for connections from a central, acting itself as a server.
  • Central scans for peripherals and initiates a connection acting as a client.
Let me elaborate on the responsibilities of the server versus the client. Server is the device that exposes data it controls or contains and maybe some settings. It accepts incoming commands and sends responses, notifications, and indications. Our MCU boards will act as sensors, providing noise levels. The noise level can be queried by a command, but it can also be sent as a notification periodically or when it changes. Client is the device that connects to the server and sends commands to read or change, if possible, the exposed data, and request and accept notifications when the data changes. The collecting station will act as a client of our MCU boards, in order to retrieve noise measurements from them.

Setup

I am going to start with the Raspberry Pi Pico (RP2) and I will use MicroPython for the first implementation of a BLE peripheral. The goal of this implementation is to read information from the microphone and make the measurements available through a BLE connection, but we are going to build that firmware iteratively.

Development environment

Installing a new firmware on the RP2 is quite easy because it supports USB Flashing Format (UF2), a mechanism created by Microsoft and implemented by some boards that emulates a storage device when connected to the USB port. You can then drop a file into that storage device in a special format. The file contains the firmware that you want to install with some metadata and redundancy and, after some basic verifications, it gets flashed to the microcontroller automatically.
In this case, we are going to flash the latest version of MicroPython to the RP2. We press and hold down the BOOTSEL button while we plug the board to the USB, and we drop the latest firmware UF2 file into the USB mass storage device that appears and that is called RPI-RP2. The firmware will be flashed and the board rebooted. RP2 Bootsel We are going to use VSCode to work with the RP2. The MicroPico extension will help us run code in the RP2. If you plan to install it, I recommend you to create a profile so you can have different extensions for different boards if needed. In this profile, you can also install the recommended Python extensions to help you with the python code.
Let's start by creating a new directory for our project and open VSCode there:
1mkdir BLE-periph-RP2
2cd BLE-periph-RP2
3code .
Then, let's initialize the project so code completion works. From the main menu, select View -> Command Palette (or Command + Shift + P) and find MicroPico: Configure Project. This command will add a file to the project and various buttons to the bottom left of your editor that will allow you to upload the files to the board, execute them, and reset it, among other things.
You can find all of the code that is explained in the repository. Feel free to make pull requests where you see they fit or ask questions.

Testing environment

Since we are only going to develop the BLE peripheral, we will need some existing tool to act as the BLE central. There are several free mobile apps available that will do that. I am going to use "nRF Connect for Mobile" (Android or iOS), but there are others that can help too, like LightBlue (macOS/iOS or Android).

BLE sensor implementation

First steps

  1. MicroPython loads and executes code stored in two files, called boot.py and main.py, in that order. The first one is used to configure some board features, like network or peripherals, just once and only after (re)starting the board. It must only contain that to avoid booting problems. The main.py file gets loaded and executed by MicroPython right after boot.py, if it exists, and that contains the application code. Unless explicitly configured, main.py runs in a loop, but it can be stopped more easily. In our case, we don't need any prior configuration, so let's start with a main.py file.
  2. Let's start by blinking the builtin LED. So the first thing that we are going to need is a module that allows us to work with the different capabilities of the board. That module is named machine and we import it, just to have access to the pins:
    1from machine import Pin
  3. We then get an instance of the pin that is connected to the LED that we'll use to output voltage, switching it on or off:
    1led = Pin('LED', Pin.OUT)
  4. We create an infinite loop and turn on and off the LED with the methods of that name, or better yet, with the toggle() method.
    1while True:
    2 led.toggle()
  5. This is going to switch the led on and off so fast that we won't be able to see it, so let's introduce a delay, importing the time module:
    1import time
    2
    3while True:
    4 time.sleep_ms(500)
  6. Run the code using the Run button at the left bottom of VSCode and see the LED blinking. Yay!

Read from a sensor

Our devices are going to be measuring the noise level from a microphone and sending it to the collecting station. However, our Raspberry Pi Pico doesn't have a microphone builtin, so we are going to start by using the temperature sensor that the RP2 has to get some measurements.
  1. First, we import the analog-to-digital-converting capabilities:
    1from machine import ADC
  2. The onboard sensor is on the fifth (index 4) ADC channel, so we get a variable pointing to it:
    1adc = ADC(4)
  3. In the main loop, read the voltage. It is a 16-bit unsigned integer, in the range 0V to 3.3V, that converts into degrees Celsius according to the specs of the sensor. Print the value:
    1temperature = 27.0 - ((adc.read_u16() * 3.3 / 65535) - 0.706) / 0.001721
    2print("T: {}ºC".format(temperature))
  4. We run this new version of the code and the measurements should be updated every half a second.

BLE peripheral GAP

We are going to start by advertising the device name and its characteristics. That is done with the Generic Access Profile (GAP) for the peripheral role. We could use the low level interface to Bluetooth provided by the bluetooth module or the higher level interface provided by aioble. The latter is simpler and recommended in the MicroPython manual, but the documentation is a little bit lacking. We are going to start with this one and read its source code when in doubt.
  1. We will start by importing the aioble and bluetooth, i.e. the low level bluetooth (used here only for the UUIDs):
    1import aioble
    2import bluetooth
  2. All devices must be able to identify themselves via the Device Information Service, identified with the UUID 0x180A. We start by creating this service:
    1# Constants for the device information service
    2_SVC_DEVICE_INFO = bluetooth.UUID(0x180A)
    3svc_dev_info = aioble.Service(_SVC_DEVICE_INFO)
  3. Then, we are going to add some read-only characteristics to that service, with initial values that won't change:
    1_CHAR_MANUFACTURER_NAME_STR = bluetooth.UUID(0x2A29)
    2_CHAR_MODEL_NUMBER_STR = bluetooth.UUID(0x2A24)
    3_CHAR_SERIAL_NUMBER_STR = bluetooth.UUID(0x2A25)
    4_CHAR_FIRMWARE_REV_STR = bluetooth.UUID(0x2A26)
    5_CHAR_HARDWARE_REV_STR = bluetooth.UUID(0x2A27)
    6aioble.Characteristic(svc_dev_info, _CHAR_MANUFACTURER_NAME_STR, read=True, initial='Jorge')
    7aioble.Characteristic(svc_dev_info, _CHAR_MODEL_NUMBER_STR, read=True, initial='J-0001')
    8aioble.Characteristic(svc_dev_info, _CHAR_SERIAL_NUMBER_STR, read=True, initial='J-0001-0000')
    9aioble.Characteristic(svc_dev_info, _CHAR_FIRMWARE_REV_STR, read=True, initial='0.0.1')
    10aioble.Characteristic(svc_dev_info, _CHAR_HARDWARE_REV_STR, read=True, initial='0.0.1')
  4. Now that the service is created with the relevant characteristics, we register it:
    1aioble.register_services(svc_dev_info)
  5. We can now create an asynchronous task that will take care of handling the connections. By definition, our peripheral can only be connected to one central device. We enable the Generic Access Protocol (GAP), a.k.a General Access service, by starting to advertise the registered services and thus, we accept connections. We could disallow connections (connect=False) for connection-less devices, such as beacons. Device name and appearance are mandatory characteristics of GAP, so they are parameters of the advertise() method.
    1from micropython import const
    2
    3_ADVERTISING_INTERVAL_US = const(200_000)
    4_APPEARANCE = const(0x0552) # Multi-sensor
    5
    6async def task_peripheral():
    7 """ Task to handle advertising and connections """
    8 while True:
    9 async with await aioble.advertise(
    10 _ADVERTISING_INTERVAL_US,
    11 name='RP2-SENSOR',
    12 appearance=_APPEARANCE,
    13 services=[_DEVICE_INFO_SVC]
    14 ) as connection:
    15 print("Connected from ", connection.device)
    16 await connection.disconnected() # NOT connection.disconnect()
    17 print("Disconnect")
  6. It would be useful to know when this peripheral is connected so we can do what is needed. We create a global boolean variable and expose it to be changed in the task for the peripheral:
    1connected=False
    2
    3async def task_peripheral():
    4 """ Task to handle advertising and connections """
    5 global connected
    6 while True:
    7 connected = False
    8 async with await aioble.advertise(
    9 _ADVERTISING_INTERVAL_MS,
    10 appearance=_APPEARANCE,
    11 name='RP2-SENSOR',
    12 services=[_SVC_DEVICE_INFO]
    13 ) as connection:
    14 print("Connected from ", connection.device)
    15 connected = True
  7. We can provide visual feedback about the connection status in another task:
    1async def task_flash_led():
    2 """ Blink the on-board LED, faster if disconnected and slower if connected """
    3 BLINK_DELAY_MS_FAST = const(100)
    4 BLINK_DELAY_MS_SLOW = const(500)
    5 while True:
    6 led.toggle()
    7 if connected:
    8 await asyncio.sleep_ms(BLINK_DELAY_MS_SLOW)
    9 else:
    10 await asyncio.sleep_ms(BLINK_DELAY_MS_FAST)
  8. Next, we import asyncio to use it with the async/await mechanism:
    1import uasyncio as asyncio
  9. And move the sensor read into another task:
    1async def task_sensor():
    2 """ Task to handle sensor measures """
    3 while True:
    4 temperature = 27.0 - ((adc.read_u16() * 3.3 / 65535) - 0.706) / 0.001721
    5 print("T: {}°C".format(temperature))
    6 time.sleep_ms(_TEMP_MEASUREMENT_INTERVAL_MS)
  10. We define a constant for the interval between temperature measurements:
    1_TEMP_MEASUREMENT_INTERVAL_MS = const(15_000)
  11. And replace the delay with an asynchronous compatible implementation:
    1await asyncio.sleep_ms(_TEMP_MEASUREMENT_FREQUENCY)
  12. We delete the import of the time module that we won't be needing anymore.
  13. Finally, we create a main function where all the tasks are instantiated:
    1async def main():
    2 """ Create all the tasks """
    3 tasks = [
    4 asyncio.create_task(task_peripheral()),
    5 asyncio.create_task(task_flash_led()),
    6 asyncio.create_task(task_sensor()),
    7 ]
    8 asyncio.gather(*tasks)
  14. And launch main when the program starts:
    1asyncio.run(main())
  15. Wash, rinse, repeat. I mean, run it and try to connect to the device using one of the applications mentioned above. You should be able to find and read the hard-coded characteristics.

Add a sensor service

  1. We define a new service, like what we did with the device info one. In this case, it is an Environmental Sensing Service (ESS) that exposes one or more characteristics for different types of environmental measurements.
    1# Constants for the Environmental Sensing Service
    2_SVC_ENVIRONM_SENSING = bluetooth.UUID(0x181A)
    3svc_env_sensing = aioble.Service(_SVC_ENVIRONM_SENSING)
  2. We also define a characteristic for… yes, you guessed it, a temperature measurement:
    1_CHAR_TEMP_MEASUREMENT = bluetooth.UUID(0x2A1C)
    2temperature_char = aioble.Characteristic(svc_env_sensing, _CHAR_TEMP_MEASUREMENT, read=True)
  3. We then add the service to the one that we registered:
    1aioble.register_services(svc_dev_info, svc_env_sensing)
  4. And also to the services that get advertised:
    1services=[_SVC_DEVICE_INFO, _SVC_ENVIRONM_SENSING]
  5. The format in which the data must be written is specified in the "GATT Specification Supplement" document. My advice is that before you select the characteristic that you are going to use, you check the data that is going to be contained there. For this characteristic, we need to encode the temperature encoded as a IEEE 11073-20601 memfloat32 :cool: :
    1def _encode_ieee11073(value, precision=2):
    2 """ Binary representation of float value as IEEE-11073:20601 32-bit FLOAT """
    3 return int(value * (10 ** precision)).to_bytes(3, 'little', True) + struct.pack('<b', -precision)
  6. We precede the data with a byte containing flags, set to zero (meaning that the temperature is provided in Celsius, and there is no timestamp or information about where the temperature is taken) and write the 5 bytes to the characteristic:
    1temperature_char.write(struct.pack("<B4s", 0, _encode_ieee11073(temperature)))
  7. Finally, we unset the timeout to await for disconnecting from the connection, in order to avoid undesired disconnections:
    1await connection.disconnected(timeout_ms=None)
  8. We can run this code in the RP2 and query this new service and the previous one using one of the previously mentioned apps.

Add notifications

The "GATT Specification Supplement" document states that notifications should be implemented adding a "Client Characteristic Configuration" descriptor, where they get enabled and initiated. Once the notifications are enabled, they should obey the trigger conditions set in the "ES Trigger Setting" descriptor. If two or three (max allowed) trigger descriptors are defined for the same characteristic, then the "ES Configuration" descriptor must be present too to define if the triggers should be combined with OR or AND. Also, to change the values of these descriptors, client binding --i.e. persistent pairing-- is required.
This is a lot of work for a proof of concept, so we are going to simplify it by notifying every time the sensor is read. Let me make myself clear, this is not the way it should be done. We are cutting corners here, but my understanding at this point in the project is that we can postpone this part of the implementation because it does not affect the viability of our device. We add a to-do to remind us later that we will need to do this, if we decide to go with Bluetooth sensors over MQTT.
  1. We change the characteristic declaration to enable notifications:
    1temperature_char = aioble.Characteristic(svc_env_sensing, _CHAR_TEMP_MEASUREMENT, read=True, notify=True)
  2. We add a descriptor, although we are going to ignore it for now:
    1_DESC_ES_TRIGGER_SETTING = bluetooth.UUID(0x290D)
    2aioble.Descriptor(temperature_char, _DESC_ES_TRIGGER_SETTING, write=True, initial=struct.pack("<B", 0))
  3. We create a global variable for the connection:
    1connection = None
  4. We give access to that variable in the peripheral task:
    1global connected, connection
  5. We set it back to None when the connection is closed:
    1connection = None
  6. And we simply invoke the notify method with the same payload:
    1payload = struct.pack("<B4s", 0, _encode_ieee11073(temperature))
    2temperature_char.write(payload)
    3if connection is not None:
    4 temperature_char.notify(connection, payload)
  7. All good notifications come to an end. But before moving on, run it and check that you can receive the notifications on the client app.

Recap

In this article, I have covered some relevant Bluetooth Low Energy concepts and put them in practice by using them in writing the firmware of a Raspberry Pi Pico board. In this firmware, I used the on-board LED, read from the on-board temperature sensor, and implemented a BLE peripheral that offered two services and a characteristic that depended on measured data and could push notifications.
We haven't connected a microphone to the board or read noise levels using it yet. I have decided to postpone this until we have decided which mechanism will be used to send the data from the sensors to the collecting stations: BLE or MQTT. If, for any reason, I have to switch boards while implementing the next steps, this time investment would be lost. So, it seems reasonable to move this part to later in our development effort.
In my next article, I will guide you through how we need to interact with Bluetooth from the command line and how Bluetooth can be used for our software using DBus. The goal is to understand what we need to do in order to move from theory to practice using C++ later.
If you have questions or feedback, join me in the MongoDB Developer Community!

Facebook Icontwitter iconlinkedin icon
Rate this tutorial
star-empty
star-empty
star-empty
star-empty
star-empty
This is part of a series
Adventures in IoT with MongoDB
Up Next
Continue

More in this series
Related
Tutorial

Getting Started With MongoDB and C++


Aug 14, 2024 | 7 min read
Tutorial

CMake + Conan + VS Code


Sep 04, 2024 | 6 min read
Tutorial

Plans and Hardware Selection for a Hands-on Implementation of IoT with MCUs and MongoDB


Aug 28, 2024 | 11 min read
Tutorial

Me and the Devil BlueZ: Implementing a BLE Central in Linux - Part 1


Dec 14, 2023 | 10 min read
Table of Contents
  • Table of Contents