Building an Interactive LED Controller with M5Dial and WLED
2026-07-15 | By Travis Foss
License: Attribution Non-commercial Addressable LEDs Color Encoder LED Strips Rotary / Selector WS2812/SK6812 (NeoPixel) ESP32 M5Stack
When DigiKey heads to events like OpenSauce, one of the things I think about is how to make our booth presence feel genuinely interactive, not just a table full of products to look at. I wanted to build something people can actually reach out and use, and hopefully inspire them to take the idea and use the inspiration from it to build their own. LED strips are a natural fit for that. They're visible from across a room, and if you hand someone control over them, you've got their attention for the next few minutes.
I’ve worked with WLED before; however, typically, you need to have a phone or laptop to connect to the WLED app or webpage in order to control the LEDs. A laptop running a web UI or a phone showing an app isn't great for a booth environment. Touchscreens with no physical feedback feel disconnected from the lights. What I wanted was something more like a proper instrument, a physical controller with a round display, a rotary encoder, and a touchscreen that people could figure out in about three seconds without any explanation from me.
The M5Stack M5Dial turned out to be exactly the right piece of hardware for this. Pair it with an Adafruit SparkleMotion stick running WLED and a WS2812B strip, and you have a self-contained interactive LED control system that works without any external network, survives a full day of event traffic, and looks good doing it.
This guide walks through the full build, hardware wiring, WLED configuration, and the Arduino firmware that drives the M5Dial's display and controls.
What You'll Need
Hardware
M5Stack M5Dial —ESP32-S3, 240×240 round GC9A01 display, rotary encoder, capacitive touch
Adafruit SparkleMotion Stick -ESP32-S2 running WLED, onboard LED driver
Addressable LED strip – I used one that was 60 LEDs/meter; however, any would work
5V power supply – rated for your strip length (60 LEDs/m at full white draws ~3.6A/meter)
Short USB-C cable – for connecting M5Dial to power
Pigtail connector - for connecting Sparklemotion stick to LED strip
Software
Arduino IDE 2.x with M5Stack board support installed
M5Dial library (install via Library Manager: search "M5Dial")
ArduinoJson v6.x (install via Library Manager: search "ArduinoJson" by Benoit Blanchon)
WLED — pre-installed on SparkleMotion, or flash the latest from Install WLED
How the System Works
Before diving into wiring and code, it helps to understand the architecture, because this project doesn't work the way you might expect.
The M5Dial and the SparkleMotion don't communicate over a serial wire or I2C. They communicate over WiFi, using WLED's built-in JSON HTTP API. The SparkleMotion runs WLED in Access Point (AP) mode, which means it creates its own local WiFi network. The M5Dial connects to that network as a client and sends HTTP POST requests to change the LED state.
M5Dial (ESP32-S3) → WiFi → WLED HTTP API on SparkleMotion → LED Strip
The reason I went this route rather than wiring them directly is that WLED already handles everything on the LED side, including timing, effects, brightness, and color correction, and its HTTP API is well-documented and stable. The M5Dial's job is just to be a great physical interface. Keeping them on WiFi means the M5Dial firmware doesn't need to know anything about WS2812B protocol or LED strip timing. It sends a JSON payload, and WLED handles the rest.
The practical advantage for a booth setup is that nothing depends on the venue's WiFi. The SparkleMotion's AP is always at the same IP address (4.3.2.1 in WLED's AP mode default), so the M5Dial always knows where to find it.
Step 1: Wire the SparkleMotion to the LED Strip
The nice thing about the SparkleMotion Stick is that it is able to power LEDs directly, although the output is a bit limited. The USB plug powers the ESP32 and LED strip. The Sparklemotion stick is capable of up to a 2A output, so it probably won’t run super long strips of LEDs, but can power shorter runs nicely.
Plug the pigtail into the connector on your light strip.
Verify which color wire on the pigtail connects to the 5V line on the LED strip, and connect that wire to the 5V output connector on the SparkMotion.
Connect the ground from the pigtail to the SparkleMotion's GND pad.
Connect the final pigtail cable from the DIN line on the LED strip to the SparkleMotion's DATA output pad.
Step 2: Configure WLED in AP Mode
Power up the SparkleMotion via USB. On first boot, WLED creates its own AP called WLED-AP with the password wled1234. Connect to it from your phone or laptop and navigate to 4.3.2.1 in a browser.
Once you're in the WLED UI:
Go to Config → LED Preferences and set your LED count to match your strip.
Go to Config → WiFi Setup and scroll to the Access Point section. Set the AP SSID and password to whatever you want; just make sure they match what you put in config.h later. I left mine as the defaults for this build.
Set AP Mode to Always so the SparkleMotion doesn't try to join any other network.
Save and reboot.
After the reboot, your SparkleMotion will always come up as its own WiFi network, at its own IP, independent of anything else in the room. That's the reliability I needed for a booth environment.
Step 3: Set Up the Arduino Project
The M5Dial firmware lives in a folder called m5dial_wled containing four files. Arduino's build system picks up all four automatically when you open the .ino with no manual linking needed.
m5dial_wled/
├── m5dial_wled.ino ← main loop and state machine
├── config.h ← WiFi credentials, constants, tuning values
├── wled_api.h ← WLED API class declaration
└── wled_api.cpp ← WLED API implementation
Open config.h first and update the WiFi credentials to match your WLED AP:
#define WLED_AP_SSID "WLED-AP" // your WLED AP name #define WLED_AP_PASS "wled1234" // your WLED AP password #define WLED_IP "4.3.2.1" // default WLED AP IP
The rest of the constants in config.h control things like how long the device waits before returning to attract mode (ATTRACT_TIMEOUT_MS), how sensitive the encoder is (WHEEL_OUTER_R, SWATCH_RADIUS), and which WLED effects cycle during attract mode (ATTRACT_EFFECTS).
Step 4: Understand the Firmware Architecture
I want to spend some time on how the firmware is structured, because a few decisions in here are worth understanding if you want to modify or extend this for your own build.
The State Machine
The firmware runs a four-state machine that determines what the display shows and what the controls do:
ATTRACT MODE
Auto-cycles through a preset list of WLED effects every 5 seconds. Displays the effect name with a pulsing "TURN or TOUCH to take control" prompt.
COLOR MODE
Shows a full-screen HSV color wheel. The encoder changes hue; touching the wheel sets both hue and saturation directly from the touch position.
EFFECT MODE
A scrollable list of every effect name fetched from WLED at boot. Turn the encoder to scroll and press to apply.
BRIGHTNESS/SPEED MODE
Dual-arc control for brightness and effect speed. Press to toggle which arc the encoder adjusts.
Any encoder turn or screen touch wakes the device from ATTRACT mode and drops it into COLOR. A 30-second idle timer returns it to ATTRACT. Long-pressing the encoder button cycles through COLOR → EFFECT → BRIGHT → COLOR.
I chose the attract-mode pattern specifically for booth use. When no one is interacting with it, the LEDs keep running visually interesting effects, which is what draws people over in the first place. The moment someone reaches out, they land on the color wheel, which is the most immediately satisfying thing to play with.
The WLED API Layer
wled_api.cpp handles all communication with WLED. The key design decision here was throttling. The rotary encoder can fire 20+ position changes per second when someone spins it fast, and sending an HTTP POST to WLED for every single tick would flood it and cause stuttering. Instead, the tick() method checks a 50ms timer:
void WledApi::tick() {
if (_dirty && (millis() - lastSendMs >= WLEDSEND_INTERVAL_MS)) {
sendCurrentState();
}
}The setters (setHSV(), setBrightness(), etc.) just mark the state as dirty. The actual HTTP call happens at most 20 times per second, which is plenty responsive while keeping WLED happy.
The JSON payload for a state change looks like this:
POST http://4.3.2.1/json/state
{
"on": true,
"bri": 200,
"seg": [{ "col": [[255, 80, 0]], "fx": 9, "sx": 128, "ix": 128 }]
}bri is global brightness (0–255). Inside the segment: col is the primary color as [R, G, B], fx is the effect index, sx is the effect speed, and ix is the effect intensity.
On boot, wled_api.cpp also makes a GET request to /json/effects to pull the full list of effect names. WLED returns a JSON array of 100+ strings. I store those in a std::vector<String> and use them to populate the effect picker screen. This means the list is always accurate to whatever version of WLED is running, no hardcoding required.
The Color Wheel
The color wheel is the most involved piece of rendering in the project, and it's worth explaining how it is supposed to work.
At boot, buildColorWheelSprite() renders a 240×240 HSV color wheel directly onto the canvas using canvas.drawPixel(). For each pixel, the angle from the center maps to hue, and the distance from the center maps to saturation. The center is white (saturation 0), and the outer rim is fully vivid (saturation 1). After rendering, the pixel buffer is saved with memcpy() for fast per-frame restoration.
float hue = fmodf(atan2f(dy, dx) * RAD_TO_DEG + 90.0f + 360.0f, 360.0f); float sat = constrain(dist / (float)WHEEL_OUTER_R, 0.0f, 1.0f);
The +90.0f offset rotates the wheel so red sits at 12 o'clock instead of 3 o'clock, which felt more natural during testing.
In every frame in color mode, the wheel is restored from the saved buffer with memcpy() (~1ms), then the cursor and center swatch are drawn on top. The cursor dot sits exactly at the position corresponding to the currently selected hue and saturation, so it always lands on the pixel color that's playing on the LEDs.
When someone touches the wheel, the touch coordinates are reverse-converted using the same math to recover hue and saturation from position:
float hue = fmodf(atan2f(dy, dx) * RAD_TO_DEG + 90.0f + 360.0f, 360.0f); float sat = constrain(dist / (float)WHEEL_OUTER_R, 0.0f, 1.0f);
The math is identical in both directions, rendering and input, which is what makes the touch response feel accurate.
With that said, I had issues getting the color wheel to show up before the event. It showed in the little selection circle, but the rest of the screen appeared black. I’m still actively working on getting the color wheel to show up and will probably post discoveries I have in the comments section below.
Double-Buffered Rendering
During early testing of the color wheel, one issue I ran into early during development was display flicker. When you draw directly to the display and start with a black fill, there's a visible flash of black between each frame. The fix is a sprite-based double buffer: all drawing targets an offscreen LGFX_Sprite canvas, and only the final, complete frame gets pushed to the display with canvas.pushSprite(0, 0). The display never sees a partial frame.
canvas.fillSprite(COL_BG); // clear offscreen // ... draw everything ... canvas.pushSprite(0, 0); // push complete frame to display
Encoder Acceleration
The encoder natively reports one count per detent. At a fixed step size, slow turns feel precise, but fast turns feel sluggish; you'd have to spin forever to get across a full hue rotation. I added an acceleration multiplier based on how many counts accumulate between loop iterations:
int encoderAccel(long delta) {
int d = abs((int)delta);
if (d >= 5) return 6;
else if (d >= 3) return 4;
else if (d >= 2) return 2;
else return 1;
}At the 200Hz loop rate (5ms delay), a slow, deliberate turn produces delta=1 (1× multiplier, precise), and a fast spin produces delta=5+ (6× multiplier, covers the full range in a few turns). This is the difference between an encoder that feels like a quality instrument and one that feels like a chore.
Step 5: Select the Board and Flash
In Arduino IDE:
Install the M5Stack board support package if you haven't: File → Preferences → Additional Boards Manager URLs, add https://static-cdn.m5stack.com/resource/arduino/package_m5stack_index.json. Then open Tools → Board → Boards Manager and install M5Stack.
Select Tools → Board → M5Stack → M5Dial.
Set Tools → PSRAM → OPI PSRAM — this is required. The color wheel buffer is 115KB and needs PSRAM.
Select your port.
Click Upload.
On first boot, you should see:
[BOOT] m5dial_wled starting
[WLED] Connecting to 'WLED-AP'.......... OK
[WLED] Loaded 118 effects.
[WHEEL] Color wheel ready.
[MODE] -> COLOR
If the WiFi connection fails, double-check your config.h credentials and confirm WLED is in AP mode (the SparkleMotion's onboard LED should be a solid color, not flashing).
Step 6: Using the Controller
Once everything is running, the interaction model is:
Touch the color wheel to pick a hue and saturation directly — the LEDs update in real time as you drag.
Turn the encoder on the color wheel screen to rotate through hues while keeping saturation.
Long-press the encoder button to cycle from color → effects → brightness/speed → back to color. Three dots at the bottom of the screen show which mode you're in.
Walk away for 30 seconds, and the device returns to attract mode, cycling through a curated set of effects automatically.
On the effects screen, turning the encoder scrolls through the list (with acceleration for fast browsing), and a short press applies the selected effect. On the brightness/speed screen, a short press toggles which arc the encoder controls.
What I'd Do Differently (and What Comes Next)
A few things I discovered during the build that are worth knowing if you replicate this:
The attract-mode effect list in config.h uses WLED effect IDs, which are stable across WLED versions, but worth verifying if you're running an older or customized firmware. I found that effects like Fire 2012 (ID 66) and Meteor (ID 56) read best from a distance, while simpler effects like Rainbow (ID 9) and Colorwaves (ID 38) work well too but have less stop-and-stare energy.
The 50ms WLED API throttle works well for the encoder, but can make touch drag feel slightly delayed at very high speeds. Dropping to 30ms would improve that responsiveness; however, I just haven't tested whether it stresses the SparkleMotion's HTTP stack at high scroll rates.
For a follow-up, I want to add a palette picker mode that lets users apply WLED's built-in color palettes alongside an effect, which gives much richer visual results than a solid primary color with an effect applied. WLED exposes palettes at /json/palettes using the same pattern as the effects list, so the framework is already there.
Also, the rotation functionality on the effect brightness and speed dials doesn't work perfectly. When slow scrolling, it sometimes jumps back to a higher or lower value than you're currently at, so that will need to be fixed. Other than those small changes, the project works really well.
I hope this build inspires you to build something similar. After OpenSauce 2026, I’m planning on adding this to my work desk to control the underlighting that I am currently working on installing as I type this. I think it would add a SciFi/Techy look to my desk that will make it cooler to look at. I’d love to see what you build from this. Post pictures of your builds in the comments below.

