Demo Input/Output Kit - Advanced BLE project
1. Introduction
Who is this tutorial for?
This tutorial is intended for developers who have already completed the Demo Input/Output Kit basic tutorial and want to go further by adding Bluetooth Low Energy (BLE) connectivity to the project. It covers a multi-file C++ project structure and shows how to integrate the NimBLE Arduino library into an OpenIndus project. By the end of this tutorial, you will be able to communicate with the Android app downloaded in the previous tutorial, monitor sensor values remotely, and control the display mode in real time.
Prerequisites
Completion of the Demo Input/Output Kit basic tutorial
Familiarity with multi-file C++ projects (headers, source files, includes)
Basic knowledge of Git (cloning a repository)
The OpenIndus VS Code extension installed - see Environment Installation
Basic understanding of BLE concepts (GATT services and characteristics, notifications, read/write operations)
What will you learn?
How to structure a multi-file embedded C++ project
How to add a third-party Arduino library (NimBLE) to an OpenIndus project via
CMakeLists.txtHow to implement a BLE GATT server to expose sensor data and receive remote commands
How to monitor temperature, humidity, distance, RGB color and potentiometer values remotely from the Android app
2. Get the project from GitHub
Rather than building the BLE project from scratch, you can clone it directly from the OpenIndus GitHub repository. Open a terminal and run:
git clone https://github.com/openindus/io-demo-kit.git
Then open the folder in VS Code with the OpenIndus extension active. The extension will detect the project and let you compile and flash it directly.
Note
Make sure you have Git installed on your machine. If you do not, download it from git-scm.com.
3. Adding the NimBLE library to the project
The BLE functionality relies on the NimBLE-Arduino library. Unlike the standard Arduino libraries bundled with OpenIndus, this one must be declared explicitly in the build system.
Referencing the libraries in CMakeLists.txt
Open the main/CMakeLists.txt file:
FILE(GLOB_RECURSE app_sources ${CMAKE_CURRENT_LIST_DIR}/*.cpp)
idf_component_register(SRCS ${app_sources}
INCLUDE_DIRS .
PRIV_REQUIRES openindus arduino bt NimBLE-Arduino
REQUIRES Adafruit-GFX-Library Adafruit_SSD1306 Adafruit_Sensor Adafruit_AM2320)
The key additions compared to a basic project are:
bt NimBLE-Arduinoin thePRIV_REQUIRESlist — the ESP-IDF Bluetooth stack and the NimBLE Arduino wrapper library that provides the C++ BLE API used inble.cppAdafruit-GFX-Library Adafruit_SSD1306 Adafruit_Sensor Adafruit_AM2320in theREQUIRESlist — the Adafruit libraries needed for the OLED display and the temperature/humidity sensor
FILE(GLOB_RECURSE app_sources ...) automatically collects all .cpp files found
in the main/ folder so any new file you add to that directory is compiled automatically.
NimBLE-Arduino component wrapper
The NimBLE-Arduino library is included directly in
a lib/NimBLE-Arduino/ folder. To make it available to the ESP-IDF build system, a CMakeLists.txt file is added to the root of the NimBLE-Arduino folder with the following content:
# Collect all .cpp files from submodule
file(GLOB_RECURSE srcs "./src/*.cpp")
# Register ESP-IDF component
idf_component_register(
SRCS ${srcs}
INCLUDE_DIRS "./src"
REQUIRES bt nvs_flash arduino
)
# NimBLE specific configuration
target_compile_definitions(${COMPONENT_LIB} PUBLIC
CONFIG_BT_NIMBLE_ENABLED=1
CONFIG_NIMBLE_CPP_IDF=1
)
The overall folder layout is therefore:
project/
├── CMakeLists.txt ← sets EXTRA_COMPONENT_DIRS
├── main/
│ ├── CMakeLists.txt ← requires NimBLE-Arduino + Adafruit libs
│ ├── main.cpp
│ ├── ble.cpp / ble.h
│ └── inouts.cpp / inouts.h
├── components/
│ ├── openindus/ ← standard OpenIndus component (untouched)
│ ├── arduino/ ← standard OpenIndus Arduino component (untouched)
└── lib/
├── Adafruit_AM2320/
├── Adafruit_BusIO/
├── Adafruit_GFX_Library/
├── Adafruit_Sensor/
├── Adafruit_SSD1306/
└── NimBLE-Arduino/
├── src/
└── CMakeLists.txt ← Added to NimBLE-Arduino to register it as an ESP-IDF component
4. Project structure
The BLE project is split into three files under the main/ directory, each with a distinct
responsibility:
File |
Role |
|---|---|
|
Hardware abstraction layer: OI module declarations, pin constants, sensor reads, actuator control, interrupt handlers |
|
BLE GATT server: service and characteristic setup, notification loop, remote command callbacks |
|
Arduino entry point: calls |
This separation keeps main.cpp clean and readable. Adding new hardware features only requires
changes to inouts.cpp, and extending the BLE interface only requires changes to ble.cpp.
inouts.h - hardware abstraction interface
inouts.h declares all the functions that main.cpp and ble.cpp use to interact with
the hardware. The doxygen-style comments document each function’s purpose and return type:
int get_display_mode();
void displaymodechange(void*);
uint8_t get_button();
void setupIOs(void inductive_sensor_callback(void*));
int get_inductive_sensor();
void set_blue_led(uint8_t level);
float get_temperature();
float get_humidity();
float measuredistance();
void set_pwm_white_led(int level);
void set_rgb_pwm(int currentColorValueRed, int currentColorValueGreen, int currentColorValueBlue);
uint32_t get_rgb();
int get_pot_pin_value();
By including only this header, ble.cpp and main.cpp can call any hardware function without
knowing the underlying pin assignments or OI module API details.
inouts.cpp - hardware abstraction layer
inouts.cpp owns all interactions with the OpenIndus hardware. The OI module objects are
declared here, not in main.cpp:
OICore core;
OIMixed mixed1;
Adafruit_AM2320 th_sensor = Adafruit_AM2320();
This keeps main.cpp free of hardware detail. All other files access the hardware exclusively
through the functions declared in inouts.h.
Key constants and pin assignments:
// Input pins
const DIn_Num_t BUTTON_PIN = DIN_1;
const DIn_Num_t INDUCTIVE_SENSOR_PIN = DIN_2;
const AnalogInput_Num_t POTENTIOMETER_PIN = AIN_1;
const AnalogInput_Num_t PROXIMITY_SENSOR_PIN = AIN_2;
// Output pins
const DOut_Num_t RED_LED_PIN = DOUT_1;
const DOut_Num_t GREEN_LED_PIN = DOUT_2;
const DOut_Num_t BLUE_LED_PIN = DOUT_3;
const DOut_Num_t WHITE_LED_PIN = DOUT_4;
The display_mode variable is managed internally by inouts.cpp with a software debounce
mechanism (same approach as in the basic tutorial). It is exposed through get_display_mode()
and can be advanced remotely via displaymodechange().
setupIOs() configures all analog inputs, PWM outputs, and interrupt handlers in one call:
void setupIOs(void inductive_sensor_callback(void*))
{
// Potentiometer: voltage mode, 0-5.12 V range
mixed1.analogInputMode(POTENTIOMETER_PIN, AIN_MODE_VOLTAGE);
mixed1.analogInputVoltageRange(POTENTIOMETER_PIN, AIN_VOLTAGE_RANGE_0_5V12);
// Proximity sensor: current mode (4-20 mA)
mixed1.analogInputMode(PROXIMITY_SENSOR_PIN, AIN_MODE_CURRENT);
// RGB LED: PWM mode at 100 Hz
mixed1.outputMode(RED_LED_PIN, DOUT_MODE_PWM);
mixed1.outputMode(GREEN_LED_PIN, DOUT_MODE_PWM);
mixed1.outputMode(BLUE_LED_PIN, DOUT_MODE_PWM);
mixed1.setPWMFrequency(RED_LED_PIN, 100);
mixed1.setPWMFrequency(GREEN_LED_PIN, 100);
mixed1.setPWMFrequency(BLUE_LED_PIN, 100);
mixed1.setPWMDutyCycle(RED_LED_PIN, 50);
mixed1.setPWMDutyCycle(GREEN_LED_PIN, 100);
mixed1.setPWMDutyCycle(BLUE_LED_PIN, 10);
// White LED: PWM mode at 100 Hz, initially off
mixed1.outputMode(WHITE_LED_PIN, DOUT_MODE_PWM);
mixed1.setPWMFrequency(WHITE_LED_PIN, 100);
mixed1.setPWMDutyCycle(WHITE_LED_PIN, 0);
// Interrupts
core.attachInterrupt(BUTTON_PIN, displaymodechange, RISING_MODE, NULL);
core.attachInterrupt(INDUCTIVE_SENSOR_PIN, inductive_sensor_callback, CHANGE_MODE, NULL);
}
The measuredistance() function acquires multiple samples from the 4-20 mA proximity sensor,
computes the median, and clamps the result to the 10–100 mm range:
float measuredistance()
{
const int nSAMPLES = 10;
static std::vector<float> rdist(nSAMPLES);
for (int i = 0; i < nSAMPLES; i++) {
rdist[i] = 5.625 * mixed1.analogReadMilliAmp(PROXIMITY_SENSOR_PIN) - 12.5;
delay(5);
}
std::sort(rdist.begin(), rdist.end());
size_t size = rdist.size();
float meandistance = (size % 2 == 0)
? (rdist[size / 2 - 1] + rdist[size / 2]) / 2.0
: rdist[size / 2];
if (meandistance > 100) return 100;
if (meandistance < 10) return 10;
return meandistance;
}
set_rgb_pwm() applies the three duty-cycle values and packs the result into a 0xRRGGBB
integer that the BLE layer can read via get_rgb():
void set_rgb_pwm(int currentColorValueRed, int currentColorValueGreen, int currentColorValueBlue)
{
mixed1.setPWMDutyCycle(RED_LED_PIN, currentColorValueRed);
mixed1.setPWMDutyCycle(GREEN_LED_PIN, currentColorValueGreen);
mixed1.setPWMDutyCycle(BLUE_LED_PIN, currentColorValueBlue);
// Convert 0-100 to 0-255 and pack into 0xRRGGBB
uint8_t r8 = (uint8_t)((currentColorValueRed * 255) / 100);
uint8_t g8 = (uint8_t)((currentColorValueGreen * 255) / 100);
uint8_t b8 = (uint8_t)((currentColorValueBlue * 255) / 100);
current_rgb_color = (r8 << 16) | (g8 << 8) | b8;
}
main.cpp - entry point
main.cpp contains the OLED display logic, the FreeRTOS background tasks, and the Arduino
entry points. It includes only inouts.h, ble.h, and the Adafruit display headers:
#include "inouts.h"
#include "ble.h"
#include "Adafruit_SSD1306.h"
#include "Fonts/FreeMono9pt7b.h"
Global variables and display constants:
#define PWM_MAX_VAL 100
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3D
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
float meandistance;
PWM_MAX_VAL is set to 100 to match the duty-cycle percentage scale used in this
multi-file version (as opposed to the 14-bit raw value in the single-file basic tutorial).
Inductive sensor callback:
void inductive_sensor_callback(void*)
{
static int inductive_sensor;
inductive_sensor = get_inductive_sensor();
if (inductive_sensor == 1)
set_blue_led(HIGH);
else
set_blue_led(LOW);
}
This interrupt callback is passed to setupIOs() and mirrors the inductive sensor state onto
the blue LED of the OI-Core, just as in the basic tutorial.
Display helper functions (displaytemperature(), displayhumidity(), displaydistance())
each clear the OLED and draw a single measurement. They call get_temperature(),
get_humidity(), and use the global meandistance variable respectively.
FreeRTOS tasks:
managedistance()— continuously reads the proximity sensor viameasuredistance()and adjusts the white LED brightness proportionally to the measured distance.rgb_pot()— continuously reads the potentiometer viaget_pot_pin_value()and maps the value to an RGB color wheel, driving the RGB LED viaset_rgb_pwm().
setup():
void setup(void)
{
Serial.begin(115200);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 allocation failed"));
for (;;);
}
display.setFont((const GFXfont *)&FreeMono9pt7b);
display.display();
delay(2000);
display.clearDisplay();
setupIOs(inductive_sensor_callback);
xTaskCreate(managedistance, "Measure distance", 20000, NULL, 1, NULL);
xTaskCreate(rgb_pot, "rgb_pot", 10000, NULL, 2, NULL);
blesetup();
Serial.println(F("Setup done."));
}
setup() initializes the serial port, the OLED display, all I/O through setupIOs(), spawns
the two background FreeRTOS tasks, and finally starts the BLE GATT server with blesetup().
loop():
void loop(void)
{
switch (get_display_mode()) {
case 0:
default:
displaytemperature();
break;
case 1:
displayhumidity();
break;
case 2:
displaydistance();
break;
}
bleloop();
delay(100);
}
loop() cycles through the three display modes (temperature, humidity, distance) based on the
value returned by get_display_mode(), then calls bleloop() to send BLE notifications.
The delay(100) yields the CPU to the FreeRTOS scheduler so that the background tasks get
regular execution time.
5. BLE GATT server
The BLE layer exposes a single GATT service with UUID A000 that groups all I/O-related
characteristics. It is set up in blesetup() and maintained by bleloop().
Service and characteristics overview
UUID |
Description |
Type |
Properties |
|---|---|---|---|
|
Demo IOs service |
Service |
— |
|
Push button state / display mode change |
|
Read / Write / Notify |
|
Temperature (°C) |
|
Read / Notify |
|
Humidity (%) |
|
Read / Notify |
|
Distance (mm) |
|
Read / Notify |
|
RGB LED color ( |
|
Read / Notify |
|
Potentiometer value (0–100) |
|
Read / Notify |
|
Inductive sensor state |
|
Read / Notify |
The Write property on the button characteristic (0000) is what allows the Android app to
simulate a button press remotely: writing to this characteristic calls displaymodechange()
and advances the OLED display mode, just as a physical button press would.
ble.h - UUID definitions
All service and characteristic UUIDs are defined as macros in ble.h, along with shorthand
property aliases:
#define ble_R NIMBLE_PROPERTY::READ
#define ble_W NIMBLE_PROPERTY::WRITE
#define ble_N NIMBLE_PROPERTY::NOTIFY
#define SERVICE_UUID "A000"
#define CHAR_UUID_BUTTON "0000"
#define CHAR_UUID_TEMPERATURE "0001"
#define CHAR_UUID_HUMIDITY "0002"
#define CHAR_UUID_DISTANCE "0003"
#define CHAR_UUID_RGB "0004"
#define CHAR_UUID_POT "0005"
#define CHAR_UUID_INDUCTIVE "0006"
void blesetup(void);
void bleloop(void);
blesetup() - GATT server initialization
blesetup() initializes the NimBLE stack, creates the server, then builds each characteristic
and assigns its callback handler before starting the service and advertising:
void blesetup(void) {
NimBLEDevice::init("NimBLE");
NimBLEDevice::setSecurityAuth(BLE_SM_PAIR_AUTHREQ_SC);
pServer = NimBLEDevice::createServer();
pServer->setCallbacks(&serverCallbacks);
NimBLEService* pDemoIosService = pServer->createService(SERVICE_UUID);
// Button - readable, writable (remote press) and notifiable
NimBLECharacteristic* pButtonCharacteristic = pDemoIosService->createCharacteristic(
CHAR_UUID_BUTTON,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::WRITE,
sizeof(uint8_t)
);
pButtonCharacteristic->setValue(get_button());
pButtonCharacteristic->setCallbacks(&buttonChrCallbacks);
// Temperature - read and notify only
NimBLECharacteristic* pTemperatureCharacteristic = pDemoIosService->createCharacteristic(
CHAR_UUID_TEMPERATURE,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY,
sizeof(float)
);
pTemperatureCharacteristic->setValue(get_temperature());
// ... (humidity, distance, RGB, potentiometer, inductive follow the same pattern)
pDemoIosService->start();
NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("OI Demo IOs");
pAdvertising->addServiceUUID(pDemoIosService->getUUID());
pAdvertising->enableScanResponse(true);
pAdvertising->start();
}
The device advertises itself as “OI Demo IOs” — the name you will see when the Android app scans for nearby devices.
Each characteristic is created with:
A UUID from
ble.hProperties that define whether it can be read, written, or notified
A size matching the data type (
sizeof(float)for sensor values,sizeof(uint8_t)for boolean states,sizeof(uint32_t)for the RGB color)A NimBLE2904 descriptor that advertises the data format to BLE clients (e.g.
FORMAT_FLOAT32,FORMAT_UINT8,FORMAT_UINT32,FORMAT_BOOLEAN)
Characteristic callbacks
The only writable characteristic is the button (0000). It has a dedicated callback class
that calls displaymodechange() from inouts.h to advance the display mode:
class buttonCharacteristicCallbacks : public CharacteristicCallbacks {
void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override {
std::string dscVal = pCharacteristic->getValue();
Serial.printf("Button written value: %s\n", dscVal.c_str());
displaymodechange(NULL);
}
} buttonChrCallbacks;
Writing any value to this characteristic triggers a display mode change, cycling through temperature → humidity → distance, exactly as pressing the physical push button does.
All other characteristics (temperature, humidity, distance, RGB, potentiometer, inductive sensor)
are read-only from the client’s perspective and use the generic CharacteristicCallbacks base
class for logging reads and subscriptions.
bleloop() - sending notifications
bleloop() is called every 100 ms from loop(). It checks whether any client is connected and,
for each characteristic, compares the current value with the last notified value. A notification is
sent only when something actually changed, keeping the BLE traffic minimal:
void bleloop() {
if (pServer->getConnectedCount()) {
NimBLEService* pSvc = pServer->getServiceByUUID(SERVICE_UUID);
if (pSvc) {
// Button state
NimBLECharacteristic* pChr = pSvc->getCharacteristic(CHAR_UUID_BUTTON);
if (pChr) {
static uint8_t lastButtonState = 0;
uint8_t buttonState = get_button();
if (buttonState != lastButtonState) {
lastButtonState = buttonState;
pChr->setValue(buttonState);
pChr->notify();
}
}
// Temperature (notify only if >= 0.1 °C change)
pChr = pSvc->getCharacteristic(CHAR_UUID_TEMPERATURE);
if (pChr) {
static float lastTemperature = 0;
float temperature = get_temperature();
pChr->setValue(temperature);
if (abs(temperature - lastTemperature) >= 0.1) {
lastTemperature = temperature;
pChr->notify();
}
}
// ... (identical pattern for humidity, distance, RGB, pot, inductive)
}
}
}
The static keyword inside each if block means each last* variable retains its value
between calls to bleloop() without needing a global variable.
Notification thresholds vary by characteristic type:
Button and inductive sensor — notify on any state change
Temperature and humidity — notify when the delta exceeds 0.1 (°C or %)
Distance — notify when the delta exceeds 0.01 mm
RGB and potentiometer — notify on any value change
Connection and security callbacks
ServerCallbacks handles connection lifecycle events. On connect, it requests updated
connection parameters to balance latency and power consumption. On disconnect, it restarts
advertising so the device is immediately discoverable again:
void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override {
pServer->updateConnParams(connInfo.getConnHandle(), 24, 48, 0, 180);
}
void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override {
NimBLEDevice::startAdvertising();
}
6. Adapting the project to your own needs
This project is designed to be a starting point. Here are the most common customisations:
Add a new BLE characteristic — declare its UUID in
ble.h, create it inblesetup(), add its notification logic tobleloop(), and expose a helper function ininouts.h/inouts.cpp.Add a new hardware function — implement it in
inouts.cppand declare it ininouts.h.main.cppandble.cppcan then call it without knowing any hardware details.Rename the BLE device — change
"OI Demo IOs"inpAdvertising->setName(...)insideblesetup()to any name you like.Add a writable characteristic — follow the
buttonCharacteristicCallbackspattern: create a callback class, overrideonWrite(), extract the value, and call the appropriate function frominouts.h.Change sensor configuration — edit the
setupIOs()function ininouts.cpp. For example, change the analog input range or PWM frequency to match new hardware.