Today I made a BLE RC car
Part 3 — Designing and implementing the BLE controller & Assembling the electrictronics
So, you came back to hear the rest of the story, ha?
Good. I like brave readers!
In the previous post
- I learned how to play with bluetoothctl for connecting and accessing the ESP32 Service I implemented.
- I’ve implemented a simple C++ client to connect to the ESP32 over BLE and turn on the LED.
Now that I know how to read & write data over BLE characteristics, I can start working on the BLE RC Car controller!
Tearing up things
Taking apart the RC car was embarrassingly easy — 3 screws and that’s it:
The car contained a few interesting electronics inside, so the dissection was completed quickly
Building a BLE remote controller car
For this project, I used
- ESP32 (Support BLE 4.2)
- HW-092 motor driver
- 1k resistor
- LED
- DC to DC stepdown converter (For powering up the ESP32)
Soldering and cable management
Eventually, I'll stuff the car with electronics, so I must manage everything tight and clean. Everything will be connected with connectors rather than soldering.
If we look at schematics, it's a simple circuit that connects the ESP32 to the Steering and Movement motors over a DC motor driver.
I’m not new to these things,
so it went quickly…
Writing the code, on the other hand, I take seriously (always)
If you are interested (and brave enough), you are welcome to visit my repo
This project contains two parts
- Client (C++, runs anywhere that supports BLE, but I plan to run it on my laptop)
- Server (C++, runs on the ESP32)
The client
After learning BLE over C++ with SimpleBluez examples, I had everything I needed to implement my own BLE client to connect to the Server exposed on the ESP32 and use exposed characteristics to drive the Car and read data from it.
The client implements an IController interface, making communicating with the car Server easier.
Since the client is not really connected to any physical hardware, no other dependencies are going on.
I implemented the client with an RAII methodology in mind:
// Constructor
BLERCCar_client::BLERCCar_client(/* args */)
{
spdlog::set_level(spdlog::level::debug); // Set global log level to info
m_Bluez.init();
m_Async_thread_arr["BluezKeepAlive"] = new std::thread([&]()
{
while (m_AsyncThreadActive){
m_Bluez.run_async();
std::this_thread::sleep_for(std::chrono::microseconds(100));
}});
spdlog::info("Welcome to spdlog version {}.{}.{} !", SPDLOG_VER_MAJOR, SPDLOG_VER_MINOR, SPDLOG_VER_PATCH);
}
Since the client is a BLE client, it does not need to declare anything in BLE, for a client connects to a [known in advance] device mac-address. and expects [also known in advance] specific service and its characteristics
So how do commands arrive at the Car? you probably ask.
A good question!
Since the client implements IController, it must have TurnLeft implemented as well.
Let's see what it looks like:
void BLERCCar_client::TurnLeft(const char percentage)
{
spdlog::debug("Start {}", __PRETTY_FUNCTION__);
auto steering_characteristic = m_CharMap[CHARACTERISTIC_UUID_STEERING].second;
BLESteerLeftPacket packet{percentage};
// The actuall part where I access BlueZ and send over DBus my payload
steering_characteristic->write_command(SimpleBluez::ByteArray(packet.GetPayload()));
spdlog::debug("Finish {}", __PRETTY_FUNCTION__);
}
Client implementation aims for a super-friendly experience and intuitive usage:
#include "BLERCCar_client.hpp"
int main(int argc, char *argv[])
{
auto server{"78:E3:6D:65:45:22"};
BLERCCar_client car_client{};
if (!car_client.Connect(server))
{
spdlog::error("Couldn't connect to car server on: {}", server);
return 1;
}
car_client.TurnLeft(100);
// RAII takes the heavy lifting of Distructing all client's objects
return 0;
}
The client is actually a library, and you can import it to whatever project you’d like (I know for a fact that I have future plans for this Client/Server Car)
The server
Why such an overkill design for a DIY project, you ask?
The way I see it, each project is an opportunity to revisit and improve software-engineering skills.
So let us dig in :)
The BLERCcar.ino file — initiates the Server on the ESP32
This is the actual Arduino file that I wrote for this project.
like any other microcontroller — It consists of 2 parts:
The setup part
Here I initiate two objects:
IController *my_car;
ble::BLEManager *ble_mgr;
void setup()
{
Serial.begin(115200);
my_car = new car::Car();
ble_mgr = new ble::BLEManager(my_car);
Serial.println("Waiting a client connection to notify...");
}
Everything else is RAII, which makes things much cleaner, simpler, and easier to develop & integrate.
The loop part:
My server is mostly event-driven — with callback attached to BLE stuff, so this loop is empty.
void loop()
{
Serial.println("Nothing to do...");
sleep(1);
}
- Everytime the EPS32 boots — it runs the setup() then loop()
The car has 2 motor objects:
car::Car::Car()
{
Serial.println("Car is initializing...");
if (initMotors())
{
Serial.println("Car is initialized");
return;
}
Serial.println("Failed to initialize");
}
bool car::Car::initMotors()
{
// Motor A
driveShaft = Motor(0, 2, 15, 0);
Serial.println("driveShaft initialized");
// Motor B
steering = Motor(16, 4, 17, 1);
Serial.println("steering initialized");
return true;
};
Motor objects are assigned with GPIO pins and PWM channels so that I can control the car easily.
NOTE:
PWM — allows controlling the motor’s position/rotation.
Without its control, the motors would have been binary-like.
I,e: Full_On or Full_off
Car also implements <<Icontroller>>
class Icontroller
{
public:
virtual void TurnLeft(const char percentage) = 0;
virtual void TurnRight(const char percentage) = 0;
virtual void SetSpeed(const char speed) = 0;
virtual void SetSpeed(const DriveMode &mode, const char speed) = 0;
virtual void SetDriveMode(DriveMode mode) = 0;
virtual const DriveMode CurrentDriveMode() = 0;
virtual ~Icontroller(){};
};
On the ServerSide,
IController implements the physical GPIO settings and Motor powering.
class Car : public Icontroller
{
private:
DriveMode m_mode;
Motor m_DriveShaftMotor, m_SteeringMotor;
bool initMotors();
void stop(bool zero_steer);
void moveForward();
void moveBackward();
void zeroSteer();
public:
Car();
~Car() override { Serial.println("Car destructed..."); };
void TurnLeft(const char percentage) override;
void TurnRight(const char percentage) override;
void SetSpeed(const char speed) override;
void SetSpeed(const DriveMode &mode, const char speed) override{};
void SetDriveMode(DriveMode mode) override;
const DriveMode CurrentDriveMode() override { return m_mode; };
const std::string CurrentDriveModeStr() override { return mode_to_str(m_mode); };
};
For example, here’s the TurnLeft implementation:
void car::Car::TurnLeft(const char percentage)
{
if (percentage == 0)
zeroSteer();
Serial.println(__PRETTY_FUNCTION__);
m_SteeringMotor.SetSpeed(percentage);
Serial.printf("TurnLeft [%d]%%\n", m_SteeringMotor.CurrentSpeedPercentage());
Serial.printf("Writing Pin1[%d] = HIGH\n", m_SteeringMotor.Pin1);
Serial.printf("Writing Pin2[%d] = LOW\n", m_SteeringMotor.Pin2);
digitalWrite(m_SteeringMotor.Pin1, HIGH);
digitalWrite(m_SteeringMotor.Pin2, LOW);
}
In the code above, the HW-095 (DC motor driver) is “programmed,” and the relevant motor reacts.
Okay, the Car is cool, right? — But where’s the BLE stuff?
Good question!
BLEManager
- Uses ESP32_BLE_SERVER
- Implement: ServerCallbacks and CharacteristicsCallbacks
- Uses BLEContext_struct and shares it with its other components
class BLEManager
{
private:
MyServerCallbacks *m_pServerCallbacks{NULL};
MyCharCallbacks *m_pMyCharCallbacks{NULL};
BLEServer *m_pServer{NULL};
BLECharacteristic *m_pTxCharacteristic{NULL};
Context m_BLEManager_ctx;
public:
const Context &GetContext() const { return m_BLEManager_ctx; };
BLEManager(Icontroller *controller);
void Advertise();
~BLEManager();
};
The magic happen in setup() (lets remember):
void setup()
{
Serial.begin(115200);
my_car = new car::Car();
// Inject a pointer of IController to BLEManager at contruction time
ble_mgr = new ble::BLEManager(my_car);
Serial.println("Waiting a client connection to notify...");
}
Construction BLEManager initiates a “Context” and shares it with its components so that they can access *IController:
ble::BLEManager::BLEManager(Icontroller *controller)
{
m_BLEManager_ctx.Controller = controller; // Preare the shared context
InitConnectivityLEDStuff(this); // This is something real cool!
// Create the BLE Device
BLEDevice::init("BLE_RC_Car");
// Create the BLE Char Callbacks with chared Context
m_pMyCharCallbacks = new MyCharCallbacks(m_BLEManager_ctx);
// Create the BLE Server Callbacks with chared Context
m_pServerCallbacks = new MyServerCallbacks(m_BLEManager_ctx);
// Create the BLE Server
// The actuall BLE server (This is ESP32 implementation
m_pServer = BLEDevice::createServer();
m_pServer->setCallbacks(m_pServerCallbacks);
// Create the BLE Service
BLEService *pService = m_pServer->createService(CAR_BLE_SERVICE_UUID);
BLECharacteristic *pReceiveDriveModeCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID_DRIVE_MODES,
BLECharacteristic::PROPERTY_WRITE);
pReceiveDriveModeCharacteristic->setCallbacks(m_pMyCharCallbacks);
BLECharacteristic *pReceiveSteeringCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID_STEERING,
BLECharacteristic::PROPERTY_WRITE);
pReceiveSteeringCharacteristic->setCallbacks(m_pMyCharCallbacks);
// Start the service
pService->start();
// Start advertising
m_pServer->startAdvertising();
}
MyCharCallbacks is where BLE meets logic
When a connected client writes something to one of the exposed characteristics, the onWrite implemented callback is triggered
void onWrite(BLECharacteristic *pCharacteristic, esp_ble_gatts_cb_param_t *param) override
{
Serial.println("Got onWrite callback");
Serial.println(pCharacteristic->getUUID().toString().c_str());
if (pCharacteristic->getUUID().toString() == CHARACTERISTIC_UUID_DRIVE_MODES)
{
auto raw = (char *)(pCharacteristic->getData());
// Convert Raw data into DriveModes packet
BLEDrivePacket packet{raw};
if (packet.GetDriveMode() == DriveMode::Unsupported)
{
Serial.printf("[warn][CHARACTERISTIC_UUID_DRIVE_MODES] onRead callback triggered with UNSUPPORTED DRIVEMODE: %d !!!\n", raw[0]);
return;
}
m_BLEManager_context.Controller->SetDriveMode(packet.GetDriveMode());
m_BLEManager_context.Controller->SetSpeed(packet.GetAmount());
}
if (pCharacteristic->getUUID().toString() == CHARACTERISTIC_UUID_STEERING)
{
// STEERING Characteristic specific logic
}
}
That’s all it is on Server,
we have covered Car & BLEManager
Summup
Feeeiwwww, what a post, ha?
Wait, am I the only one arrived so far in the post?
Hope not :)
I transitioned from zero to hero in BLE:
- Learned BLE stack architecture and what fits where
- Learned BLE Service, and Characteristics
- Played with my beloved ESP32 once more
- Used some advanced C++ design patterns
- I have even leveraged my BLE design knowledge and discovered a bug in the design!
See you next time.
Cheers, Gal.