Demo Motors Kit - Advanced BLE project
1. Introduction
Who is this tutorial for?
This tutorial is intended for developers who have already completed the Demo Motors 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, control the motors remotely, and monitor their state in real time.
Prerequisites
Completion of the Demo Motors 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 motor state and receive remote commands
How to control motor speed and trigger buttons 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/motor-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.
Download the library with git submodules
The NimBLE-Arduino library is not copied into the repository: it is referenced as a Git submodule. A submodule is a pointer to a specific commit of an external repository. This means the project source tree stays lean and the library can be updated independently, while everyone working on the project uses exactly the same library version.
After cloning, the NimBLE-Arduino/ directory exists but is empty. You must initialise and fetch the
submodule explicitly:
git submodule update --init --recursive
--initregisters any submodule that has not been set up yet in your local clone.--recursivealso initialises submodules nested inside submodules (NimBLE-Arduino has none, but it is good practice to always use it).
Once this command completes, NimBLE-Arduino/src/ is populated with the library source and the project
is ready to build.
Referencing the library in CMakeLists.txt
Open the main/CMakeLists.txt file:
FILE(GLOB_RECURSE app_sources ${CMAKE_SOURCE_DIR}/main/*.*)
idf_component_register(SRCS ${app_sources}
PRIV_REQUIRES openindus arduino bt NimBLE-Arduino
INCLUDE_DIRS "")
The key addition compared to a basic project is bt NimBLE-Arduino in the PRIV_REQUIRES list:
bt- the ESP-IDF Bluetooth stack, required as a low-level dependencyNimBLE-Arduino- the NimBLE Arduino wrapper library that provides the C++ BLE API used inble.cpp
FILE(GLOB_RECURSE app_sources ...) automatically collects all .cpp and .h files found
in the main/ folder so any new file you add to that directory is compiled automatically.
Root CMakeLists.txt - registering the submodule directory
The root CMakeLists.txt at the project root tells ESP-IDF where to find extra components:
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(EXTRA_COMPONENT_DIRS components/submodules)
idf_build_set_property(MINIMAL_BUILD ON)
project(kit_controle_moteur)
The critical line is set(EXTRA_COMPONENT_DIRS components/submodules). ESP-IDF scans every
directory listed in EXTRA_COMPONENT_DIRS and treats each sub-folder that contains a
CMakeLists.txt as a buildable component. This is how NimBLE-Arduino is discovered and
made available to the rest of the project.
idf_build_set_property(MINIMAL_BUILD ON) reduces build time by skipping ESP-IDF components
that are not explicitly required by any component in the dependency graph.
NimBLE-Arduino component wrapper
As explained earlier, the NimBLE-Arduino library is included as a Git submodule (the actual library source sits in
a components/NimBLE-Arduino/ folder at the repository root). To integrate it with ESP-IDF without
touching the submodule itself, a thin wrapper component lives in
components/submodules/NimBLE-Arduino/:
# Collect all .cpp files from the submodule source tree
file(GLOB_RECURSE srcs "../../NimBLE-Arduino/src/*.cpp")
# Register as an ESP-IDF component
idf_component_register(
SRCS ${srcs}
INCLUDE_DIRS "../../NimBLE-Arduino/src"
REQUIRES bt nvs_flash arduino
)
# NimBLE compile-time flags
target_compile_definitions(${COMPONENT_LIB} PUBLIC
CONFIG_BT_NIMBLE_ENABLED=1
CONFIG_NIMBLE_CPP_IDF=1
)
The relative path ../../NimBLE-Arduino/src walks up two levels from the wrapper folder
(components/submodules/NimBLE-Arduino/) to reach the actual library source at the repository
root. This keeps the component registration entirely outside of the submodule directory so that
pulling library updates with git submodule update never overwrites build configuration.
The overall folder layout is therefore:
project/
├── CMakeLists.txt ← sets EXTRA_COMPONENT_DIRS
├── main/
│ ├── CMakeLists.txt ← requires NimBLE-Arduino
│ ├── main.cpp
│ ├── ble.cpp / ble.h
│ ├── commands.cpp / commands.h
│ └── system_definition.h
└── components/
├── openindus/ ← standard OpenIndus component (untouched)
├── arduino/ ← standard OpenIndus Arduino component (untouched)
├── NimBLE-Arduino/ ← git submodule (untouched)
| └── src/
└── submodules/
└── NimBLE-Arduino/
└── CMakeLists.txt ← wrapper (owned by the project)
4. Project structure
The BLE project is split into four files under the main/ directory, each with a distinct
responsibility:
File |
Role |
|---|---|
|
Pin name constants and shared data structures ( |
|
Hardware abstraction layer: motor control, LED tasks, button events, OI module declarations |
|
BLE GATT server: service and characteristic setup, notification loop, remote command callbacks |
|
Arduino entry point: calls``setup()`` / |
This separation keeps main.cpp clean and readable. Adding new hardware features only requires
changes to commands.cpp, and extending the BLE interface only requires changes to ble.cpp.
commands.cpp - hardware abstraction layer
commands.cpp owns all interactions with the OpenIndus hardware. The OI module objects are
declared here, not in main.cpp:
OICore core;
OIStepper stepper;
This keeps main.cpp free of hardware detail. All other files access the hardware exclusively
through the functions declared in commands.h.
Key state variables managed by commands:
volatile bool buttonEvent = false; // set by ISR, read by loop()
volatile bool bleLeftButtonPressed = false; // set by BLE write callback
volatile bool bleRightButtonPressed = false;
volatile float bleLeftMotorSpeed = 100; // steps/s, updated over BLE
volatile float bleRightMotorSpeed = 100;
All four variables are volatile because they are written from interrupt contexts (hardware ISR
or BLE callback tasks running on a separate RTOS task) and read from the Arduino main loop.
isLeftButtonPressed() merges both the physical and the virtual BLE button states with a
logical OR so the motor reacts to either source:
bool isLeftButtonPressed() {
return core.digitalRead(LEFT_BUTTON) || bleLeftButtonPressed;
}
runMotor() picks the BLE-commanded speed before calling the stepper:
void runMotor(MotorNum_t motor, MotorDirection_t direction) {
float speed = (motor == MOTOR_LEFT) ? bleLeftMotorSpeed : bleRightMotorSpeed;
stepper.setMaxSpeed(motor, speed);
stepper.run(motor, direction, speed);
}
main.cpp - entry point
setup() calls the init functions from commands.cpp and ble.cpp in order:
void setup() {
Serial.begin(115200);
initMotorsParameters(); // configure voltage, step mode, ramps
initLEDsTasks(); // create FreeRTOS tasks for LED blinking
initMotors(); // homing sequence + startup animation
attachInterruptions(); // register button ISRs
bleSetup(); // start NimBLE GATT server
}
loop() is intentionally minimal. It handles the button event (physical or BLE-triggered) and
then calls bleloop() to send BLE notifications:
void loop() {
if (isButtonEvent()) {
clearEvent();
stopLeftLedBlinking();
stopRightLedBlinking();
bool leftAction = isLeftButtonPressed();
bool rightAction = isRightButtonPressed();
if (leftAction && !rightAction) {
runMotor(MOTOR_LEFT, motor1_dir);
stopMotor(MOTOR_RIGHT, SOFT_HIZ);
motor2_dir = reverse_motdir(motor2_dir);
startLeftLedBlinking();
} else if (!leftAction && rightAction) {
runMotor(MOTOR_RIGHT, motor2_dir);
stopMotor(MOTOR_LEFT, SOFT_HIZ);
motor1_dir = reverse_motdir(motor1_dir);
startRightLedBlinking();
} else {
stopMotor(MOTOR_LEFT, SOFT_HIZ);
stopMotor(MOTOR_RIGHT, SOFT_HIZ);
}
}
bleloop();
delay(50);
}
5. BLE GATT server
The BLE layer exposes a single GATT service with UUID A100 that groups all motor-related
characteristics. It is set up in bleSetup() and maintained by bleloop().
Service and characteristics overview
UUID |
Description |
Type |
Properties |
|---|---|---|---|
|
Motors service |
Service |
— |
|
Left button state |
|
Read / Write / Notify |
|
Right button state |
|
Read / Write / Notify |
|
Left motor state (position + speed) |
|
Read / Notify |
|
Right motor state (position + speed) |
|
Read / Notify |
|
Left motor home switch state |
|
Read / Notify |
|
Left motor end switch state |
|
Read / Notify |
|
Right motor home switch state |
|
Read / Notify |
|
Right motor end switch state |
|
Read / Notify |
|
Left motor target speed (steps/s) |
|
Write |
|
Right motor target speed (steps/s) |
|
Write |
|
Advanced motor parameters |
|
Read / Write |
The Write property on the button characteristics (A101, A102) is what allows the
Android app to simulate a button press remotely: writing 1 sets bleLeftButtonPressed = true
and triggers the same event path as a physical button press.
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_MOTORS);
// Left button - readable, writable (remote press) and notifiable
NimBLECharacteristic* pButtonCharacteristic = pDemoIosService->createCharacteristic(
CHAR_UUID_LEFT_BUTTON,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::WRITE,
sizeof(uint8_t)
);
pButtonCharacteristic->setCallbacks(&leftButtonChrCallback);
// ... (other characteristics follow the same pattern)
pDemoIosService->start();
NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("OI Motors Kit");
pAdvertising->addServiceUUID(pDemoIosService->getUUID());
pAdvertising->start();
}
The device advertises itself as “OI Motors Kit” — the name you will see when the Android app scans for nearby devices.
Characteristic callbacks
Each writable characteristic has a dedicated callback class that inherits from
NimBLECharacteristicCallbacks. The pattern is always the same: extract the raw bytes from the
characteristic value and call the appropriate function from commands.h.
Remote button press (LeftButtonCharacteristicCallbacks):
class LeftButtonCharacteristicCallbacks : public CharacteristicCallbacks {
void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override {
std::string dscVal = pCharacteristic->getValue();
bool value = (!dscVal.empty()) ? (bool)dscVal[0] : false;
setLeftButtonPressed(value); // triggers buttonEvent = true in commands.cpp
}
};
Remote speed update (LeftMotorSetSpeedCharacteristicCallbacks):
class LeftMotorSetSpeedCharacteristicCallbacks : public CharacteristicCallbacks {
void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override {
std::string dscVal = pCharacteristic->getValue();
float value = 0;
if (dscVal.size() >= sizeof(float)) {
value = *(float*)dscVal.data(); // raw 4-byte IEEE 754 float
}
setMotorSpeed(MOTOR_LEFT, value); // stored in bleLeftMotorSpeed
}
};
The speed is transmitted as a raw 4-byte IEEE 754 float (little-endian). The Android application must pack it the same way before writing the characteristic.
bleloop() - sending notifications
bleloop() is called every 50 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_MOTORS);
if (pSvc) {
// Example: left motor state
NimBLECharacteristic* pChr = pSvc->getCharacteristic(CHAR_UUID_LEFT_MOTOR_STATE);
if (pChr) {
static MotorStatus_t lastLeftMotorStatus{};
MotorStatus_t motorStatus = getMotorStatus(MOTOR_LEFT);
if (!areStatusEqual(motorStatus, lastLeftMotorStatus)) {
lastLeftMotorStatus = motorStatus;
pChr->setValue((uint8_t*)&motorStatus, sizeof(MotorStatus_t));
pChr->notify();
}
}
// ... (identical pattern for every other characteristic)
}
}
}
The static keyword inside the if block means each last* variable retains its value
between calls to bleloop() without needing a global variable.
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 logic tobleloop(), and expose a helper function incommands.h/commands.cpp.Add a new hardware function - implement it in
commands.cppand declare it incommands.h.main.cppandble.cppcan then call it without knowing any hardware details.Rename the BLE device - change
"OI Motors Kit"inpAdvertising->setName(...)insidebleSetup()to any name you like.Change motor parameters - edit the
initMotorsParameters()function incommands.cpp. The advanced parameters can also be updated at runtime by the Android app through characteristicA10B.