24  Lab: BLE Sensor Beacon

In 60 Seconds

This hands-on lab walks you through building a complete BLE sensor beacon on ESP32, covering GATT service design, characteristic notifications for real-time sensor data, and connection event handling. Using the browser-based Wokwi simulator, you implement temperature and battery monitoring over BLE without physical hardware.

Key Concepts
  • Wokwi Simulator: Browser-based ESP32/Arduino simulator supporting BLE emulation, allowing firmware development without physical hardware
  • ESP-IDF NimBLE Stack: Lightweight BLE host stack for ESP32 with lower RAM footprint than Bluedroid; preferred for IoT sensor firmware
  • BLE Peripheral Role: Device that advertises services and responds to connection requests from a central; the “server” in GATT terminology
  • BLE Central Role: Device that scans for peripherals and initiates connections; the “client” in GATT terminology (e.g., smartphone app)
  • nRF Connect App: Nordic Semiconductor’s BLE debugging app for Android/iOS; displays all GATT services/characteristics and allows direct read/write
  • GATT Notification: Server-initiated data push to subscribed clients without requiring a read request; requires CCCD to be written to 0x0001
  • ADV_IND: Undirected connectable advertising PDU; broadcast on all three advertising channels; the most common advertising type for IoT peripherals
  • Descriptor: GATT metadata attached to a characteristic; common examples include CCCD (0x2902) for notification control and CPF (0x2904) for unit/format description
Minimum Viable Understanding

Build a BLE sensor beacon on the ESP32 that advertises standard and custom GATT services (Environmental Sensing 0x181A, Battery 0x180F), pushes real-time temperature and battery readings via notifications, and handles connect/disconnect events. The Wokwi browser simulator lets you complete the entire lab without physical hardware.

24.1 Learning Objectives

By completing this lab, you will be able to:

  • Configure BLE advertising with custom service UUIDs and device names on the ESP32
  • Implement GATT services including custom temperature and standard battery level characteristics
  • Apply BLE notifications to push sensor data to connected clients without polling
  • Construct connection event handlers for connect, disconnect, and advertising restart sequences
  • Analyze RSSI values (Received Signal Strength Indicator) to estimate proximity between devices
  • Design power-efficient BLE peripherals by calculating and comparing advertising interval tradeoffs
  • Distinguish between standard Bluetooth SIG UUIDs and custom 128-bit UUIDs and justify when each applies

In this lab, you will build a BLE beacon – a small device that periodically broadcasts sensor data (like temperature) to any nearby phone or computer that is listening. Think of it as building a tiny radio station that broadcasts sensor readings. It is one of the simplest and most practical BLE projects you can build.

“Today we build something real!” said Max the Microcontroller excitedly, holding up an ESP32 board. “We are going to create a BLE sensor beacon that broadcasts temperature readings to any phone nearby. It is like Sammy getting his own tiny radio station!”

Sammy the Sensor was thrilled. “How does it work?” Max explained, “First, we set up BLE advertising – that is like putting up a sign that says ‘Temperature Sensor Here!’ Then we create a GATT service with a temperature characteristic. When a phone connects, we push temperature updates using notifications – no need for the phone to keep asking.”

“The coolest part is notifications,” said Lila the LED. “Instead of the phone polling ‘What is the temperature? What is the temperature?’ over and over, our beacon just pushes updates whenever the value changes. It is way more efficient and saves battery on both sides.”

Bella the Battery added practical advice. “Pay attention to the advertising interval. Broadcasting every 100 milliseconds drains me fast, but every 1000 milliseconds gives a good balance between discovery speed and battery life. For a sensor that updates every few seconds, there is no reason to advertise constantly.”

The advertising interval directly affects battery life. The average current draw is:

\[I_{avg} = I_{active} \times \frac{T_{adv}}{T_{interval}} + I_{sleep} \times \left(1 - \frac{T_{adv}}{T_{interval}}\right)\]

where \(I_{active}\) is active current (12mA), \(T_{adv}\) is advertising duration (3ms), \(T_{interval}\) is the advertising interval, and \(I_{sleep}\) is sleep current (5µA).

Example: For a CR2032 battery (220mAh) with 100ms vs 1000ms intervals: - 100ms interval: \(I_{avg} = 12 \times \frac{0.003}{0.1} + 0.005 \times 0.97 = 0.365\) mA → 25 days battery life - 1000ms interval: \(I_{avg} = 12 \times \frac{0.003}{1.0} + 0.005 \times 0.997 = 0.041\) mA → 224 days battery life

The 10x slower advertising rate gives approximately 9x longer battery life with minimal impact on discoverability.

Try It: BLE Advertising Interval Battery Life Calculator

Adjust the advertising interval and see how it affects battery life for a CR2032-powered BLE beacon.

24.2 Prerequisites

  • Basic understanding of Arduino/C++ syntax
  • Familiarity with BLE concepts from this chapter series (GATT, services, characteristics, advertising)
  • No physical hardware required (browser-based simulation)

24.3 Components Used

Component Purpose Connection
ESP32 DevKit BLE-capable microcontroller Main board
NTC Thermistor (or DHT22) Temperature sensing GPIO 34 (analog)
LED Connection status indicator GPIO 2 (built-in)
Potentiometer Simulated battery voltage GPIO 35 (analog)

24.4 Key BLE Concepts in This Lab

BLE Architecture Overview

This lab demonstrates the core BLE concepts covered in this chapter series:

  1. Advertising: The ESP32 broadcasts its presence and service UUIDs so smartphones can discover it
  2. GATT Server: The ESP32 acts as a peripheral hosting services that central devices can read
  3. Services: Containers for related characteristics (we implement Environmental Sensing and Battery)
  4. Characteristics: Individual data points with properties (read, notify, indicate)
  5. Notifications: Server-initiated updates pushed to connected clients without polling
  6. Connection Parameters: Interval, latency, and timeout settings that affect power and responsiveness

24.5 Wokwi Simulator Environment

About Wokwi

Wokwi is a free online simulator for Arduino, ESP32, and other microcontrollers. It allows you to build and test IoT projects entirely in your browser without purchasing hardware. The ESP32 simulator includes BLE support, making it ideal for learning BLE concepts.

Launch the simulator below to get started. The default project includes an ESP32 - you will add components and code as you progress through the lab.

Simulator Tips
  • Click on the ESP32 to see available pins
  • Use the + button to add components (search for “NTC Temperature Sensor” or “Potentiometer”)
  • Connect wires by clicking on pins
  • The Serial Monitor shows BLE events and debug output
  • Press the green Play button to run your code
  • Use the nRF Connect app on your phone (or LightBlue) to connect to the simulated BLE device

24.6 Step-by-Step Instructions

24.6.1 Step 1: Set Up the Circuit

  1. Add an NTC Temperature Sensor: Click the + button and search for “NTC Temperature Sensor”
  2. Add a Potentiometer: Click + and search for “Potentiometer” (simulates battery voltage)
  3. Wire the components to ESP32:
    • NTC VCC -> ESP32 3.3V
    • NTC OUT -> ESP32 GPIO 34 (ADC)
    • NTC GND -> ESP32 GND
    • Potentiometer VCC -> ESP32 3.3V
    • Potentiometer Signal -> ESP32 GPIO 35 (ADC)
    • Potentiometer GND -> ESP32 GND

24.6.2 Step 2: Understanding the Code Structure

Before copying the code, understand the BLE components we will implement:

GATT Server Structure:
|
+-- Environmental Sensing Service (0x181A)
|     +-- Temperature Characteristic (0x2A6E)
|           Properties: Read, Notify
|
+-- Battery Service (0x180F)
|     +-- Battery Level Characteristic (0x2A19)
|           Properties: Read, Notify
|
+-- Custom Service (128-bit UUID)
      +-- RSSI Characteristic (128-bit UUID)
            Properties: Read, Notify

24.6.3 Step 3: Copy the BLE Sensor Beacon Code

Copy the following code into the Wokwi code editor (replace any existing code). The code is split into key sections below:

Includes and Configuration:

#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

#define TEMP_PIN 34          // NTC thermistor analog input
#define BATTERY_PIN 35       // Potentiometer (simulates battery voltage)
#define LED_PIN 2            // Built-in LED for connection status

// Standard Bluetooth SIG UUIDs
#define ENVIRONMENTAL_SENSING_SERVICE_UUID  "181A"
#define TEMPERATURE_CHAR_UUID               "2A6E"
#define BATTERY_SERVICE_UUID                "180F"
#define BATTERY_LEVEL_CHAR_UUID             "2A19"

BLEServer* pServer = nullptr;
BLECharacteristic* pTemperatureChar = nullptr;
BLECharacteristic* pBatteryChar = nullptr;
bool deviceConnected = false;

Connection Callbacks:

class MyServerCallbacks : public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
        deviceConnected = true;
        digitalWrite(LED_PIN, HIGH);
        Serial.println("CLIENT CONNECTED!");
    }
    void onDisconnect(BLEServer* pServer) {
        deviceConnected = false;
        digitalWrite(LED_PIN, LOW);
        Serial.println("CLIENT DISCONNECTED");
    }
};

GATT Service Setup (the core of the lab):

void setupBLE() {
    BLEDevice::init("IoT-Sensor-Beacon");
    pServer = BLEDevice::createServer();
    pServer->setCallbacks(new MyServerCallbacks());

    // Environmental Sensing Service
    BLEService* pEnvService = pServer->createService(
        ENVIRONMENTAL_SENSING_SERVICE_UUID);
    pTemperatureChar = pEnvService->createCharacteristic(
        TEMPERATURE_CHAR_UUID,
        BLECharacteristic::PROPERTY_READ |
        BLECharacteristic::PROPERTY_NOTIFY);
    pTemperatureChar->addDescriptor(new BLE2902()); // CCCD for notifications

    // Battery Service
    BLEService* pBatteryService = pServer->createService(BATTERY_SERVICE_UUID);
    pBatteryChar = pBatteryService->createCharacteristic(
        BATTERY_LEVEL_CHAR_UUID,
        BLECharacteristic::PROPERTY_READ |
        BLECharacteristic::PROPERTY_NOTIFY);
    pBatteryChar->addDescriptor(new BLE2902());

    pEnvService->start();
    pBatteryService->start();

    // Start advertising with service UUIDs
    BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
    pAdvertising->addServiceUUID(ENVIRONMENTAL_SENSING_SERVICE_UUID);
    pAdvertising->setScanResponse(true);
    BLEDevice::startAdvertising();
}

Main Loop – Read Sensors and Notify:

void loop() {
    if (!deviceConnected) {
        // Restart advertising after disconnect
        static bool wasConnected = false;
        if (wasConnected) {
            pServer->startAdvertising();
            wasConnected = false;
        }
        return;
    }

    static uint32_t lastNotifyTime = 0;
    if (millis() - lastNotifyTime < 1000) return;
    lastNotifyTime = millis();

    // Read temperature (sint16, resolution 0.01 C)
    int rawTemp = analogRead(TEMP_PIN);
    float voltage = rawTemp * (3.3 / 4095.0);
    float tempC = 25.0 + ((3.3 - voltage) * 10000.0 / voltage - 10000.0) / -200.0;
    int16_t tempValue = (int16_t)(tempC * 100);
    uint8_t tempData[2] = {(uint8_t)(tempValue & 0xFF),
                           (uint8_t)((tempValue >> 8) & 0xFF)};
    pTemperatureChar->setValue(tempData, 2);
    pTemperatureChar->notify();

    // Read battery level (0-100%)
    uint8_t batteryLevel = map(analogRead(BATTERY_PIN), 0, 4095, 0, 100);
    pBatteryChar->setValue(&batteryLevel, 1);
    pBatteryChar->notify();
}

24.6.4 Knowledge Check: GATT Service Design

24.6.5 Step 4: Run the Simulation

  1. Click the green Play button to start the simulation
  2. Watch the Serial Monitor for BLE initialization messages
  3. The LED will blink slowly indicating the device is advertising
  4. In a separate browser tab or on your phone, use a BLE scanner app to find “IoT-Sensor-Beacon”

24.6.6 Step 5: Connect and Test with a BLE App

Recommended BLE Apps
  • nRF Connect (iOS/Android) - Best for detailed GATT exploration
  • LightBlue (iOS/Android) - Simple and clean interface
  • BLE Scanner (Android) - Lightweight option

Testing Steps:

  1. Open your BLE scanner app and scan for devices
  2. Find “IoT-Sensor-Beacon” in the list
  3. Connect to the device
  4. Explore the services:
    • Environmental Sensing (0x181A): Contains temperature characteristic
    • Battery (0x180F): Contains battery level characteristic
    • Custom Service: Contains RSSI characteristic
  5. Enable notifications on the temperature characteristic
  6. Observe values updating every second
  7. Adjust the potentiometer in Wokwi to change the battery reading

24.7 Understanding the Code

24.7.1 UUID Selection

// Standard UUIDs (defined by Bluetooth SIG)
#define ENVIRONMENTAL_SENSING_SERVICE_UUID  "181A"  // Well-known service
#define TEMPERATURE_CHAR_UUID               "2A6E"  // Standard temperature format

// Custom UUID (generated for your application)
#define CUSTOM_SERVICE_UUID  "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
  • Standard UUIDs allow apps to automatically understand your data format
  • Custom UUIDs are used for proprietary features specific to your device

24.7.2 Characteristic Properties

BLECharacteristic::PROPERTY_READ |    // Client can read value
BLECharacteristic::PROPERTY_NOTIFY    // Server can push updates
Property Description Use Case
READ Client can request current value On-demand data retrieval
WRITE Client can set value Configuration, commands
NOTIFY Server pushes updates (unconfirmed) Real-time sensor data
INDICATE Server pushes updates (confirmed) Critical alerts

24.7.3 CCCD (Client Characteristic Configuration Descriptor)

pTemperatureChar->addDescriptor(new BLE2902());

The BLE2902 descriptor allows clients to enable/disable notifications. Without this, the notify property would not work.

24.7.4 Data Format for BLE

// BLE uses little-endian byte order
uint8_t tempData[2];
tempData[0] = tempValue & 0xFF;         // Low byte first
tempData[1] = (tempValue >> 8) & 0xFF;  // High byte second

24.8 Challenge Exercises

Challenge 1: Add Humidity Sensing

Extend the Environmental Sensing service to include humidity:

  1. Add a second sensor (DHT22) or use a second potentiometer
  2. Create a humidity characteristic (UUID: 0x2A6F)
  3. Humidity format: uint16, resolution 0.01% (so 50.00% = 5000)
  4. Add notifications for humidity updates

Hint: The humidity characteristic follows the same pattern as temperature.

Challenge 2: Implement Configurable Notification Interval

Add a writable characteristic that lets the connected client change the notification interval:

  1. Create a custom characteristic with PROPERTY_WRITE
  2. Accept values 100-10000 (milliseconds)
  3. Apply the new interval immediately
  4. Persist the setting (bonus: use EEPROM)

Why this matters: Faster updates drain battery faster. Letting users configure the interval enables power vs. responsiveness tradeoffs.

Challenge 3: Add Low Battery Alert

Create an alert system when battery drops below a threshold:

  1. Add a new characteristic for alerts (UUID: 0x2A06 - Alert Level)
  2. When battery < 20%, set alert to “Mild Alert” (value: 1)
  3. When battery < 10%, set alert to “High Alert” (value: 2)
  4. Send indication (confirmed notification) for alerts

Why indications?: Critical alerts should be confirmed to ensure the central received them.

24.9 Troubleshooting

Common Issues and Solutions

Device not appearing in BLE scan:

  • Ensure advertising is started after service setup
  • Check that service UUIDs are added to advertising packet
  • Some apps filter by service UUID - try “Show all devices”

Notifications not working:

  • Verify CCCD (BLE2902) descriptor is added to characteristic
  • Client must write 0x0001 to CCCD to enable notifications
  • Check if notification is called: pCharacteristic->notify()

Connection drops immediately:

  • Check connection interval settings (too aggressive for some clients)
  • Ensure delay() in loop is not too long (>supervision timeout)
  • Some phones disconnect if no services are discovered quickly

Values appear incorrect:

  • Verify byte order (BLE uses little-endian)
  • Check value scaling (temperature uses 0.01 resolution)
  • Ensure characteristic value size matches data type

24.10 Lab Summary

In this lab, you built a complete BLE sensor beacon that demonstrates:

Concept Implementation
BLE Advertising Device broadcasts name and service UUIDs for discovery
GATT Server ESP32 hosts services that clients can connect to
Standard Services Environmental Sensing (0x181A) and Battery (0x180F)
Characteristics Temperature (0x2A6E) and Battery Level (0x2A19) with proper formats
Notifications Real-time sensor updates pushed to connected clients
Connection Handling Callbacks for connect/disconnect events
RSSI Monitoring Signal strength for proximity estimation

24.10.1 Knowledge Check: GATT Notifications

24.10.2 Knowledge Check: BLE Data Format

Common Pitfalls

Wokwi requires explicit BLE component enablement in the ESP-IDF sdkconfig (CONFIG_BT_ENABLED=y, CONFIG_BT_NIMBLE_ENABLED=y). Projects copied from non-BLE templates will compile but the BLE stack will not initialize, producing silent failures. Always start from a BLE-specific Wokwi template or verify sdkconfig BLE settings before debugging.

Using 16-bit UUIDs like 0x1800 (Generic Access) or 0x180A (Device Information) for custom services causes BLE clients to misinterpret the service as a standard service with incorrect characteristics. Custom services must use 128-bit UUIDs generated with uuidgen (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). 16-bit UUIDs are reserved for Bluetooth SIG adopted services.

In NimBLE and Bluedroid, registering GATT services does not automatically start advertising. The ble_gap_adv_start() call (NimBLE) or esp_ble_gap_start_advertising() call (Bluedroid) must be made explicitly, typically in the BLE host sync callback. A device that initializes the stack but never calls start advertising will be invisible to scanners.

Sending a 100-byte notification without first negotiating a larger ATT MTU will cause the stack to silently truncate the payload to 20 bytes (default MTU 23 - 3 header bytes). Always perform ATT MTU exchange after connection (NimBLE: ble_gattc_exchange_mtu()) before sending large notifications, and verify the negotiated MTU with ble_att_mtu().

24.11 What’s Next

Having completed this BLE sensor beacon lab, you can deepen your Bluetooth knowledge through the following chapters:

Chapter What You Will Learn Why It Matters
BLE Code Examples and Simulators Python and Arduino BLE development patterns, including central-role scanning Implement both peripheral and central roles for full BLE systems
Bluetooth Security Pairing modes, bonding, link-layer encryption, and LE Secure Connections Protect sensor data transmitted in your beacon from eavesdropping
Bluetooth Applications Real-world BLE deployment case studies in healthcare, retail, and industry Evaluate design decisions made in production BLE systems
BLE Fundamentals Radio physics, frequency hopping, and the full BLE protocol stack Diagnose connection and range issues in deployed beacons
Bluetooth Advertising Deep Dive Extended advertising, scan response packets, and iBeacon/Eddystone formats Implement more capable discovery mechanisms beyond basic advertising
Power Optimization for BLE Connection parameters, sleep modes, and adaptive notification strategies Extend battery life beyond the 224-day baseline calculated in this lab

Scenario: Your environmental sensor beacon from the lab transmits temperature and battery level every 1 second. Users complain the coin cell battery (CR2032, 220mAh) only lasts 3 months instead of the advertised 12+ months.

Given:

  • Current configuration: Notify every 1000ms (1 second)
  • BLE radio active current: 12mA for 3ms per transmission
  • Sleep current: 5µA (ultra-low power mode)
  • Temperature changes slowly (0.1°C per minute typical)
  • Battery level changes 1% per week

Analysis:

  1. Calculate current power consumption (1-second interval):

    Duty cycle: 3ms active / 1000ms period = 0.3%
    Active power: 12mA × 0.003 = 0.036mA
    Sleep power: 0.005mA × 0.997 = 0.005mA
    Average current: 0.036 + 0.005 = 0.041mA
    
    Battery life: 220mAh / 0.041mA = 5,366 hours ≈ 224 days (7.5 months)
  2. Identify optimization opportunity:

    Temperature changes: 0.1°C per minute
    Current sampling: 60 readings per minute
    Useful readings: 1 per minute (99% redundant!)
    
    Battery level changes: 1% per week
    Current sampling: 604,800 readings per week
    Useful readings: 1 per day would suffice
  3. Design adaptive notification strategy:

    Temperature: Notify every 30 seconds (not every 1 second)
    - Still captures 0.1°C/min changes with 0.05°C granularity
    
    Battery level: Notify every 10 minutes (not every 1 second)
    - More than sufficient for 1%/week change rate
    - Create separate characteristic to avoid tight coupling
  4. Calculate power savings:

    New duty cycle:
    - Temperature: 3ms / 30,000ms = 0.01% (30-second interval)
    - Battery: 3ms / 600,000ms = 0.0005% (10-minute interval)
    - Combined: 0.0105% vs 0.3% original
    
    New average current:
    - Active: 12mA × 0.000105 = 0.00126mA
    - Sleep: 0.005mA × 0.999895 = 0.00499mA
    - Total: 0.00625mA (was 0.041mA)
    
    New battery life: 220mAh / 0.00625mA = 35,200 hours ≈ 1,467 days (4+ years!)
  5. Implementation with change-based notification (bonus optimization):

    // Add threshold-based update
    float lastNotifiedTemp = 0;
    const float TEMP_THRESHOLD = 0.5;  // Only notify on 0.5°C change
    
    void loop() {
      if (deviceConnected) {
        float currentTemp = readTemperature() / 100.0;
    
        // Only notify if significant change OR 30 seconds elapsed
        if (abs(currentTemp - lastNotifiedTemp) > TEMP_THRESHOLD ||
            (millis() - lastNotifyTime >= 30000)) {
    
          // Send notification
          pTemperatureChar->notify();
          lastNotifiedTemp = currentTemp;
          lastNotifyTime = millis();
        }
      }
      delay(1000);  // Check every second, notify selectively
    }

Result: By matching notification intervals to actual data change rates, battery life increased from 7.5 months to 4+ years (6.5× improvement) with ZERO loss of useful information. The user experience improved because fewer unnecessary notifications reduced smartphone battery drain.

Key Insight: The fastest notification interval is rarely the best. Match your notification rate to your data’s natural change rate. For slow-changing values, event-driven notifications (only send on significant change) are more efficient than periodic updates.

24.12 Summary

This hands-on lab demonstrated practical BLE development:

  • GATT service design with standard and custom UUIDs
  • Characteristic properties (Read, Notify) for different access patterns
  • Connection event handling for robust peripheral behavior
  • Data formatting with little-endian byte order for BLE compliance
  • Advertising configuration for device discovery
  • Power considerations through notification intervals