How to Send Sensor Data over BLE with STM32WBA and STM32CubeIDE

Sending sensor data over Bluetooth Low Energy using an STM32WBA microcontroller and STM32CubeIDE

ST’s official BLE examples for the STM32WBA ship with 47 source files, 3 custom profiles, and a partridge in a pear tree. You wanted to send one temperature reading over Bluetooth. Instead you got a firmware project that looks like it was designed to run a hospital.

A minimum viable BLE sensor on STM32WBA needs about 40 lines of user code across 2 files. Everything else is generated scaffolding. But figuring out which 2 files, and which 40 lines, can eat an entire weekend when you’re staring at ST’s demo projects.

This tutorial gets you from a blank STM32CubeIDE project to a working BLE temperature sensor, verified on your phone with nRF Connect, in about an hour. We’ll read the on-board temperature sensor via the ADC, expose it through a custom GATT characteristic with notifications, and stop there. No mobile app, no Python scripts, no cloud dashboards.

What You’ll Need Before Starting

Hardware: NUCLEO-WBA55CG board, a USB-C cable, and a phone with nRF Connect installed (iOS or Android).

Software: STM32CubeIDE 1.15.0 or later (earlier versions have incomplete WBA support). The STM32CubeWBA firmware package needs to be installed through CubeIDE’s embedded software packages manager (Help > Manage Embedded Software Packages > STM32WBA).

Make sure your ST-Link firmware is current. CubeIDE will usually prompt you, but if it doesn’t, run the ST-Link upgrade tool manually. A stale ST-Link causes mysterious flash failures that look like code bugs.

Creating the Project and Configuring BLE in CubeMX

Open STM32CubeIDE. File > New > STM32 Project. Use the Board Selector tab and pick NUCLEO-WBA55CG. Name it something like wba_temp_ble. When prompted to initialize peripherals in default mode, say Yes; this pre-configures clocks and pins for the Nucleo board.

Switch to the CubeMX perspective (.ioc file).

  1. In the left panel, expand Middleware and Software Packs > STM32_WPAN.
  2. Check BLE to enable it.
  3. Under GAP settings, set the role to Peripheral.
  4. Change the device name to WBA_TEMP (or whatever you want to see when scanning).
  5. Set advertising interval min/max to 100ms/200ms. Fine for a demo; you’d tune them for battery life in production.
┌─────────────────────────────────────────────┐
│           CubeMX BLE Configuration          │
├─────────────────────────────────────────────┤
│                                             │
│  Middleware & Software Packs                │
│  └── STM32_WPAN                            │
│       ├── BLE: [Enabled] ✓                  │
│       ├── GAP Role: Peripheral              │
│       ├── Adv Name: "WBA_TEMP"              │
│       └── GATT Services:                    │
│            └── Custom Service (see Step 4)  │
│                                             │
│  Analog                                     │
│  └── ADC4                                   │
│       └── IN_TEMPSENSOR: [Enabled] ✓        │
│                                             │
└─────────────────────────────────────────────┘

The BLE stack memory settings (heap size, GATT attribute count, connection buffers) have defaults that work for a single-service peripheral. Leave them alone. Changing them without understanding the memory model is the fastest way to a hard fault on startup.

Defining a Custom GATT Service and Characteristic

You could use the Bluetooth SIG’s Health Thermometer Profile here. But it drags in mandatory behaviors that get in the way: measurement intervals, CCC requirements, specific value formats. All of that obscures what’s actually happening. A custom service cuts the problem down to essentials.

In CubeMX’s GATT/GAP service configuration panel, add a new custom service.

Service UUID: 0000AA00-8E22-4541-9D4C-21EDAE82ED19

Under that service, add one characteristic:

Characteristic UUID: 0000AA01-8E22-4541-9D4C-21EDAE82ED19 Properties: Read + Notify Value length: 2 bytes

Custom GATT Structure:
═══════════════════════════════════════

Service: "Temp Sensor Service"
UUID: 0000AA00-8E22-4541-9D4C-21EDAE82ED19
│
└── Characteristic: "Temperature"
    UUID: 0000AA01-8E22-4541-9D4C-21EDAE82ED19
    Properties: Read | Notify
    Value Length: 2 bytes (int16, °C × 100)
    │
    └── Descriptor: CCCD (auto-generated)
        Enables/disables notifications

The 2-byte value will hold temperature in degrees Celsius multiplied by 100, stored as an int16. 23.84°C becomes 2384, or 0x0950 in little-endian. That’s 0.01°C resolution, which is overkill for the internal sensor but keeps the math clean.

The CCCD (Client Characteristic Configuration Descriptor) gets auto-generated when you enable Notify. This is the mechanism that lets a phone subscribe to updates. Without it, your firmware has no way to know whether anyone’s listening. If you’re building BLE-connected devices that need to reach beyond local range, services like Hubble Network can bridge that gap; their device SDK introduction explains how BLE advertisements can be picked up by satellite and terrestrial infrastructure.

Configuring the ADC for the On-Board Temperature Sensor

Still in CubeMX: expand Analog > ADC4. Enable the internal temperature sensor channel (IN_TEMPSENSOR).

Set the sampling time to at least 5µs. The STM32WBA datasheet specifies a minimum for the internal temperature sensor; anything shorter gives you noisy garbage. A setting of 160.5 ADC clock cycles at 16MHz works well.

Leave resolution at 12-bit. Generate code (Project > Generate Code).

The calibration formula uses two factory-programmed values baked into each chip’s system memory:

// TS_CAL1: ADC value at 30°C, read at VDDA = 3.0V
// TS_CAL2: ADC value at 130°C, read at VDDA = 3.0V
temp_c = 30.0f + (float)(adc_raw - *TS_CAL1) * (130.0f - 30.0f) / (*TS_CAL2 - *TS_CAL1);

We’ll turn this into integer math in the firmware section.

Understanding the Generated Project Structure

After code generation, you’ll find a lot of new files. Here’s what matters:

Project File Map:
─────────────────────────────────────────────
Core/
├── Src/
│   ├── main.c .................. App entry point
│   └── stm32wbaxx_it.c ........ Interrupt handlers
│
STM32_WPAN/
├── App/
│   ├── app_ble.c ............... BLE init, advertising ← EDIT HERE
│   ├── custom_app.c ............ Notification logic  ← EDIT HERE
│   └── custom_stm.c ............ GATT service/char    (generated)
│
Middlewares/ ........................ DO NOT EDIT
─────────────────────────────────────────────

⚠️ Never modify files under Middlewares/. They get overwritten every time you regenerate from CubeMX.

Your user code goes in app_ble.c and custom_app.c, specifically inside the /* USER CODE BEGIN */ and /* USER CODE END */ comment blocks. Code outside those markers gets wiped on the next generation pass. That’s the single most common source of “my code disappeared” frustration.

Writing the Firmware Logic

Here’s the data flow from sensor to phone:

┌──────────┐    HAL_ADC     ┌───────────┐   Format    ┌──────────────┐  BLE Notify  ┌──────────┐
│ Internal │───────────────►│ Raw ADC   │────────────►│ GATT Char    │─────────────►│ BLE      │
│ Temp     │  GetValue()    │ Value     │  int16 °C×  │ Value Buffer │  (if CCCD    │ Central  │
│ Sensor   │                │ (12-bit)  │  100        │ (2 bytes LE) │   enabled)   │ (Phone)  │
└──────────┘                └───────────┘             └──────────────┘              └──────────┘
                                │
                                ▼
                         ┌─────────────┐
                         │ Calibration │
                         │ TS_CAL1/2   │
                         └─────────────┘

Reading the Temperature Sensor

Add this function in custom_app.c (inside a USER CODE section):

static int16_t Read_Temperature_x100(void)
{
  uint32_t adc_raw;

  HAL_ADC_Start(&hadc4);
  HAL_ADC_PollForConversion(&hadc4, 100);
  adc_raw = HAL_ADC_GetValue(&hadc4);
  HAL_ADC_Stop(&hadc4);

  /* Calibration values from system memory */
  int32_t ts_cal1 = *(uint16_t*)(0x0BFA0710);  /* 30°C reference */
  int32_t ts_cal2 = *(uint16_t*)(0x0BFA0742);  /* 130°C reference */

  /* Integer math: result is °C × 100 */
  int16_t temp_x100 = (int16_t)(3000 + ((int32_t)(adc_raw - ts_cal1) * 10000) / (ts_cal2 - ts_cal1));

  return temp_x100;
}

Check your specific STM32WBA variant’s reference manual for the exact calibration register addresses. The ones above are for the WBA55; other variants might differ.

Updating the GATT Characteristic Value

In custom_app.c, find the notification update function (CubeMX generates a skeleton with a name like Custom_App_Notification()). Inside it:

void Custom_Temp_Send_Notification(void)
{
  Custom_STM_App_Update_Char_Ext_t char_update;
  int16_t temp = Read_Temperature_x100();
  uint8_t value[2];

  /* Little-endian per BLE spec */
  value[0] = (uint8_t)(temp & 0xFF);
  value[1] = (uint8_t)((temp >> 8) & 0xFF);

  char_update.Service_UUID = CUSTOM_STM_TEMP_SVC;
  char_update.Char_UUID    = CUSTOM_STM_TEMP_CHAR;
  char_update.Val_Length    = 2;
  char_update.pPayload     = value;

  Custom_STM_App_Update_Char_Ext(&char_update);
}

The exact struct and function names depend on what CubeMX generated. Look at custom_stm.c for the enum values it created for your service and characteristic UUIDs. Match those names.

Triggering Periodic Notifications

The STM32_WPAN middleware provides a software timer called UTIL_TIMER that runs off the RTC. Use it to fire notifications every 2 seconds:

/* In custom_app.c, USER CODE BEGIN section */
static UTIL_TIMER_Object_t Temp_Timer;

static void Temp_Timer_Callback(void *arg)
{
  /* Only send if a client has subscribed (CCCD enabled) */
  if (Notification_Enabled)
  {
    Custom_Temp_Send_Notification();
  }
  UTIL_TIMER_StartWithPeriod(&Temp_Timer, 2000); /* restart */
}

/* Call this during app init (USER CODE in Custom_App_Init): */
UTIL_TIMER_Create(&Temp_Timer, 2000, UTIL_TIMER_ONESHOT, Temp_Timer_Callback, NULL);
UTIL_TIMER_StartWithPeriod(&Temp_Timer, 2000);

The Notification_Enabled flag should be set/cleared in the GATT event handler when a client writes to the CCCD. CubeMX generates a callback for this in custom_app.c; look for the event that corresponds to your characteristic’s notification enable/disable. Set a static boolean there.

For patterns on structuring advertising packets and payload formats, the Hubble advertising packet documentation provides a useful reference, even if you’re building a standard BLE peripheral.

Build, Flash, and Verify with nRF Connect

Build the project (Project > Build All, or Ctrl+B). Fix any missing extern declarations for hadc4 if CubeMX didn’t propagate it to your user files (add extern ADC_HandleTypeDef hadc4; at the top of custom_app.c).

Flash via ST-Link (Run > Run As > STM32 C/C++ Application).

On your phone, open nRF Connect and scan. You should see WBA_TEMP in the device list. Connect to it. You’ll see the custom service UUID 0000AA00-.... Expand it, and you’ll find the temperature characteristic.

Tap the notification subscribe button (the triple-down-arrow icon in nRF Connect).

nRF Connect - Expected View:
═══════════════════════════════════
 Device: WBA_TEMP        [Connected]
─────────────────────────────────────
 Service: 0000AA00-8E22-...
 │
 └─ Temperature (0000AA01-8E22-...)
    Properties: READ, NOTIFY
    Value: 0x50 0x09  →  23.84°C
    [Notifications: ENABLED ▼]
═══════════════════════════════════

You should see the hex value updating every 2 seconds. Press your thumb against the MCU chip for 10 seconds and watch the value climb. That’s your verification that real sensor data is flowing through the entire pipeline.

When Things Don’t Work

  • Device doesn’t appear when scanning: Check that MX_APPE_Init() is being called in main.c. If it’s missing, CubeMX didn’t wire up the app entry point correctly. Also verify the antenna matching network on the Nucleo is populated (it should be by default).
  • Notifications never arrive: Either you haven’t subscribed via CCCD, or the Notification_Enabled flag isn’t being toggled in the GATT event handler. Add a breakpoint in the CCCD write callback to confirm.
  • ADC returns 0 or 4095: Sampling time is too short, or the ADC clock is misconfigured. Verify ADC4’s clock source and prescaler in CubeMX.
  • Hard fault on startup: Bump your heap. The BLE stack needs significant RAM, and the default linker script often sets heap to 0x200. Try 0x1000 or higher.

Adding More Characteristics and Scaling Up

You’ve got a working BLE temperature sensor in roughly 40 lines of user code. Once you understand the structure, adding a second characteristic (battery voltage, an external humidity sensor, a button state) is just repeating the GATT definition and notification steps with a new UUID and a different data source.

Logical next steps: build a simple mobile app to consume this data (Flutter with flutter_blue_plus is probably the fastest path). Add BLE pairing if you need to protect the data. Swap the internal temp sensor for an external I2C sensor like the SHT40 for actual accuracy.

If you’re thinking about scaling this to devices deployed in the field, you’ll want to consider how to register devices in bulk and pull data into your backend programmatically, rather than pointing a phone at each one.


Hubble Network enables direct-to-satellite connectivity for BLE sensors deployed anywhere—no gateways, no cellular coverage required. See how it works →