This project turns a single low-cost controller board into a wireless RGB lighting controller you can drive straight from your phone -- no app-specific hardware dongle, no separate wireless add-on module. You'll connect over Bluetooth Low Energy (BLE), send a color or brightness value from a phone app, and watch a real LED strip respond in real time.
What the Controller Does
The ESP32-C3 is the whole project's brain, and it's a genuinely good fit here for a specific reason: it has Bluetooth 5 (BLE) built directly into the chip. There's no separate Bluetooth module to wire up or configure -- the same board that runs your program also handles the wireless radio. The controller's job is threefold: advertise itself over BLE so a phone can find it, listen for incoming values written to a BLE characteristic, and translate those values into commands for the LED strip.
What the LED Strip Does
The WS2812B strip is a chain of individually addressable RGB LEDs -- each LED has its own tiny driver chip built in, so the controller can set every single LED to its own color rather than lighting the whole strip as one color. All of that data travels down a single data wire, one bit stream, in order, from the first LED to the last.
What the Buck Converter Does
The LM2596 buck converter's job here is purely about power, not data. A strip with many LEDs lit brightly at once can draw considerably more current than a controller's own onboard voltage regulator is designed to supply -- that regulator is sized for the controller's own logic, not for driving a string of LEDs. The buck converter steps down and supplies clean, adequately-rated current to the LED strip on its own dedicated power path, separate from the controller's logic power, so the strip gets what it needs without starving or browning out the controller.
How BLE Actually Connects Phone to Controller
At a simple level: the ESP32 advertises a BLE "service" containing one or more "characteristics" -- think of a characteristic as a small named mailbox the controller is watching. A phone app scans for the device, connects, and writes a value into that mailbox -- for example, a color as three RGB bytes, or a single brightness byte. The controller's code is simply waiting for that mailbox to change; when it does, it reads the new value and updates what it sends to the LED strip. There's no polling loop needed on the phone side and no custom wireless protocol to invent -- BLE handles the connection and delivery, your code just decides what to do with the bytes it receives.
Software and Library Concepts
You'll lean on two separate, well-established libraries here: a BLE server library to handle advertising and characteristic read/write events, and an addressable-LED library to handle the strip's data protocol and timing. Keep the two concerns separate in your code -- a BLE callback function that simply stores the incoming value, and a main loop (or a second callback) that applies whatever the current stored value is to the strip. Get each library working on its own with a hardcoded test value before wiring them together.
Testing and Troubleshooting
Phone can't find or connect to the device -- confirm the BLE advertising code is actually running and that the device name/service UUID in your code matches what your app is scanning for.
LEDs don't respond even though the phone shows "connected" -- check the LED strip's data pin wiring first, then confirm logic-level compatibility between the controller's output and the strip's data input.
LED colors come out wrong -- for example red shows as green -- this is a very common WS2812B gotcha: a mismatched color-order setting (RGB vs GRB) in the LED library. Check that setting before assuming a hardware fault.
Connection drops repeatedly or values arrive but seem delayed -- confirm you aren't blocking the BLE callback with slow code (like a long delay) inside the write handler itself; keep that handler fast and let the main loop do the heavier work.
Optional Future Upgrade: Multiple Lighting Zones
Once single-strip color and brightness control is solid, a natural next step is addressing sub-ranges of the same strip as independent zones -- sending a zone index alongside the color value over BLE, and updating only that portion of the strip in code -- without adding any new hardware.
Wiring
External DC supply (skip if already on 5V) → LM2596 IN+ / IN−
LM2596 OUT+ → WS2812B strip 5V (trim the LM2596 to a verified 5.0V before connecting the strip)
WS2812B DIN → ESP32-C3 SuperMini GPIO5, through a ~330–470Ω resistor in series
The ESP32-C3 only supports BLE (Bluetooth Low Energy), not classic Bluetooth, so it won't show up as a normal "Bluetooth speaker"-style pairing target — you connect to it with a BLE-capable app (a generic GATT tool like nRF Connect is the fastest way to test the characteristic before building a custom phone app). The service/characteristic UUIDs below are just arbitrary custom UUIDs (standard practice — generate your own with any UUID generator for a real product); they only need to match between this firmware and whatever app you write. The write handler accepts either 3 raw bytes (R,G,B) or a text command like `"255,0,0"`, so test both ways depending on what your BLE client sends.
Wiring Diagram
Starter Code
#include <Adafruit_NeoPixel.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#define LED_DATA_PIN 5
#define NUM_LEDS 30
#define SERVICE_UUID "5f1a3a2e-6b0e-4a7a-9b6e-2b6e2f3a9d10"
#define CHARACTERISTIC_UUID "5f1a3a2f-6b0e-4a7a-9b6e-2b6e2f3a9d10"
Adafruit_NeoPixel strip(NUM_LEDS, LED_DATA_PIN, NEO_GRB + NEO_KHZ800);
BLECharacteristic *pColorCharacteristic;
bool deviceConnected = false;
void setStripColor(uint8_t r, uint8_t g, uint8_t b) {
uint32_t color = strip.Color(r, g, b);
for (int i = 0; i < NUM_LEDS; i++) {
strip.setPixelColor(i, color);
}
strip.show();
}
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer *pServer) override {
deviceConnected = true;
Serial.println("BLE client connected");
}
void onDisconnect(BLEServer *pServer) override {
deviceConnected = false;
Serial.println("BLE client disconnected — advertising again");
pServer->getAdvertising()->start();
}
};
class ColorWriteCallback : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) override {
std::string value = pCharacteristic->getValue();
if (value.length() == 0) return;
uint8_t r = 0, g = 0, b = 0;
if (value.length() == 3) {
// Raw RGB byte triplet
r = (uint8_t)value[0];
g = (uint8_t)value[1];
b = (uint8_t)value[2];
} else {
// Text command like "255,0,0"
int r1, g1, b1;
if (sscanf(value.c_str(), "%d,%d,%d", &r1, &g1, &b1) == 3) {
r = constrain(r1, 0, 255);
g = constrain(g1, 0, 255);
b = constrain(b1, 0, 255);
} else {
Serial.println("Unrecognized color command");
return;
}
}
Serial.printf("Setting color: R=%d G=%d B=%d\n", r, g, b);
setStripColor(r, g, b);
}
};
void setup() {
Serial.begin(115200);
strip.begin();
strip.setBrightness(180);
setStripColor(0, 0, 0);
BLEDevice::init("ESP32 LED Controller");
BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
BLEService *pService = pServer->createService(SERVICE_UUID);
pColorCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE
);
pColorCharacteristic->setCallbacks(new ColorWriteCallback());
pColorCharacteristic->addDescriptor(new BLE2902());
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->start();
Serial.println("BLE advertising started.");
}
void loop() {
// All the work happens in the BLE write callback
delay(20);
}