An Introduction to PWM: Controlling RGB LED Colors with an ESP32 Potentiometer
I’ll be honest: my first attempt to build an adjustable colored lamp ended with a dead LED and a faint smell of burnt hope. I’d hooked up an RGB LED straight to the ESP32’s GPIO pins, spun a potentiometer, and expected a smooth rainbow. Instead, I got a single angry flicker, then darkness. What I’d missed wasn’t just a current‑limiting resistor — it was the whole concept of Pulse Width Modulation (PWM). Without it, the pins could only bang out full 3.3 V and the LED either glared white or stayed off. Fast forward a few late nights, a stack of datasheets, and a trusty 10k potentiometer, and I finally understood how to mix light. This tutorial walks you through the same journey — but with fewer sparks and zero magic smoke. By the end you’ll have an ESP32, a single potentiometer, and an RGB LED that cycles through the entire color wheel, all managed by ESPHome. No Arduino IDE, no manual C++ loops, just clean YAML and hardware PWM that actually works.
If you’ve already built an ESP32 weather station or tinkered with Zigbee temperature sensors, you’ll find the setup familiar. The same ESP32 Development Board Module that reads a DHT22 or talks to Home Assistant is more than capable of doing some colorful math on the side. Today we’re focusing purely on PWM — no external drivers, no I²C displays, just raw analog‑to‑digital conversion and three precisely chopped signals that fool your eyes into seeing a continuous palette.
Materials & Tools
You don’t need a lab bench full of gear. Here’s the exact BOM I used (I actually built this on a tiny breadboard while explaining the concept to my neighbor’s kid).
- ESP32-DevKit board: Buy it here (any ESP32 dev board will do; I used a generic ESP32‑WROOM‑32)
- 1 × RGB LED, common cathode (the one with four legs — longest is the common cathode)
- 3 × 220 Ω resistors (one per color channel — trust me, skipping these was my first mistake)
- 1 × 10kΩ potentiometer (any linear taper works; grab a knob if you want it to look less like a science project)
- Breadboard + jumper wires
- Micro‑USB cable (data‑capable, not just a charge‑only cable — that one stumped me for an hour)
Optional sanity helpers:
- A multimeter (to double‑check the potentiometer’s wiper voltage before firing up code)
- A handful of extra LEDs just in case you mix up common cathode vs. common anode (I’ve done that).
Wiring Guide
Take your time here — a single swapped pin won’t break anything but will turn your rainbow into a frustrating puzzle. The potentiometer gives us an analog voltage that varies from 0 V to 3.3 V, and we’ll read it on an ADC‑capable GPIO. The RGB LED’s three anodes connect to GPIOs that can output hardware PWM.
| ESP32 Pin | Component / Pin | Notes |
|---|---|---|
| GND | Potentiometer pin 1 | One outer leg |
| 3.3 V | Potentiometer pin 3 | The other outer leg |
| GPIO34 | Potentiometer pin 2 | Wiper (middle) — ADC1 channel 6 |
| GND | RGB LED common cathode | Longest leg, or the leg that’s slightly apart |
| GPIO25 | Red anode (via 220 Ω) | Connect resistor in series |
| GPIO26 | Green anode (via 220 Ω) | |
| GPIO27 | Blue anode (via 220 Ω) |
Potentiometer orientation: If you look at the front with the shaft pointing toward you and the pins at the bottom, typical layout is left = ground, middle = wiper, right = 3.3 V. Always check with a multimeter — turn the shaft fully clockwise and see which outer pin rises to 3.3 V.
RGB LED pinout: With the flat side facing you, the pins are usually Red‑Common‑Green‑Blue (common cathode). If you have a common anode, you’ll need to change the code to inverted logic, but common cathode keeps things intuitive: HIGH = light on.
⚠️ Important: Never skip the current‑limiting resistors. Each GPIO can source ~12 mA safely. Without a resistor, an LED can draw enough current to slowly cook the pin — I’m speaking from experience. 220 Ω keeps everything well within spec at 3.3 V.
After wiring, before powering on, give each connection a gentle tug. A loose potentiometer wiper will give you wild ADC readings that jump from 0 to 3.3 V sporadically, leading to a disco effect you probably didn’t ask for.
Software Setup: ESPHome on the ESP32
I’ll assume you have ESPHome installed. If not, the official ESPHome installation guide is the best place to start. I run it as a Home Assistant add‑on, but the command‑line tool works identically.
- Create a new device. From the ESPHome dashboard (or
esphome wizard), define a new node. Name it something likergb-pot. Choose “ESP32” as the board type. - Copy the YAML configuration I’ll provide below into your node’s
.yamlfile. You can also start with a minimal template and add the sensor/light blocks manually. - Create a
secrets.yaml(if you use Wi‑Fi) for your SSID and password. I always do — it’s easier to share config files without leaking credentials.
# secrets.yaml
wifi_ssid: "YourNetwork"
wifi_password: "YourPassword123"
- Install drivers if you haven’t already. On Windows, the CP210x or CH340 driver is usually needed. On Linux, you may need to add your user to the
dialoutgroup so you can flash withoutsudo.
The whole ESPHome flow — write YAML, compile, upload OTA or via USB — means you’re never locked into a decision. Changing a GPIO or tweaking a calculation takes seconds, not a full recompile in an IDE.
Configuration Walkthrough: The Full ESPHome YAML
Here’s the complete configuration. I’ll break down the key sections right after.
esphome:
name: rgb-pot
friendly_name: "RGB Potentiometer"
esp32:
board: esp32dev
framework:
type: arduino
# Enable logging
logger:
# Enable Home Assistant API (optional, but handy for remote testing)
api:
encryption:
key: !secret api_encryption_key
ota:
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap: {} # fallback hotspot
captive_portal:
# --- Potentiometer sensor ---
sensor:
- platform: adc
pin: GPIO34
id: pot_voltage
name: "Potentiometer Value"
update_interval: 100ms # 10 readings per second feels smooth
attenuation: 11db # full range 0-3.9V, we only use 0-3.3V
accuracy_decimals: 4
filters:
- sliding_window_moving_average:
window_size: 5
send_every: 5
on_value:
then:
- lambda: |-
// Map ADC reading 0.0–1.0 (ratio of reference) to a 0–360 hue angle
float hue = id(pot_voltage).state * 360.0;
float red = 0.0, green = 0.0, blue = 0.0;
if (hue >= 0 && hue < 60) {
red = 1.0;
green = hue / 60.0;
blue = 0.0;
} else if (hue >= 60 && hue < 120) {
red = (120.0 - hue) / 60.0;
green = 1.0;
blue = 0.0;
} else if (hue >= 120 && hue < 180) {
red = 0.0;
green = 1.0;
blue = (hue - 120.0) / 60.0;
} else if (hue >= 180 && hue < 240) {
red = 0.0;
green = (240.0 - hue) / 60.0;
blue = 1.0;
} else if (hue >= 240 && hue < 300) {
red = (hue - 240.0) / 60.0;
green = 0.0;
blue = 1.0;
} else {
red = 1.0;
green = 0.0;
blue = (360.0 - hue) / 60.0;
}
// Call the light with computed RGB values (automatically adjusted to 0-1 range)
id(rgb_light).set_rgb(red, green, blue);
# --- RGB LED light (PWM outputs) ---
light:
- platform: rgb
name: "RGB LED"
id: rgb_light
red: GPIO25
green: GPIO26
blue: GPIO27
How the Pieces Fit Together
ADC sensor (adc platform):
GPIO34 is one of the ESP32’s ADC1 pins, safe to use even when Wi‑Fi is active. The attenuation: 11db tells the ESP32 to measure up to roughly 3.9 V, which gives us full resolution over our 0–3.3 V range. The update interval of 100 ms makes the color change feel instantaneous to your eyes. A sliding‑window moving average smooths out the tiny electrical noise that inevitably appears on breadboards — without it, the LED would jitter between adjacent hues when you stop turning the knob.
HSV → RGB lambda:
A single potentiometer naturally controls one variable, and hue is the most satisfying one for a circular color wheel. The lambda takes the ADC value (which ESPHome normalizes to a float between 0.0 and 1.0 for the configured attenuation), multiplies by 360, and runs through a standard piecewise conversion. No external libraries needed. The math lives right in the YAML, so you can tweak it directly — want a rainbow that starts at red and ends at violet? Already there. (Later, you could add a second potentiometer for brightness, but that’s another story.)
RGB light component:
ESPHome’s rgb platform creates a light entity that controls three GPIOs with PWM. When you call id(rgb_light).set_rgb(...), the framework does all the heavy lifting: hardware LEDC channels, frequency setup, and gamma correction if you ask for it. The values red, green, blue are clamped automatically, so no risk of burning out anything with a math error.
Why ESPHome instead of Arduino C++?
I’ve done this both ways. Writing PWM and ADC code in the Arduino IDE means handling setup, loop delays, analogRead scaling, and channel configuration by hand. With ESPHome, I can compile, upload, and have a working prototype in minutes. Plus, the device automatically appears in Home Assistant, so I can later automate the lamp based on time of day or music — but that’s an enhancement for later.
Upload & Test
- Connect the ESP32 via USB and hit “Upload” in the ESPHome dashboard (or
esphome run rgb-pot.yaml). - Watch the logs. After the initial boot, you should see something like:
[I][app:105]: ESPHome version 2025.3.0 compiled on Apr 14 2025, 10:15:00 [I][adc:057]: ADC 'Potentiometer Value': Got raw=1452 voltage=1.17V - Turn the potentiometer slowly. The raw ADC value (and its normalized ratio) should change. At the same time, the RGB LED should cycle through colors — red → yellow → green → cyan → blue → magenta → back to red.
- Test the full range. Make sure that at full clockwise you get a consistent deep blue (or whatever the end of your mapping is). If the color jumps near the ends, check your
attenuationsetting or the potentiometer’s wiring.
If the LED doesn’t light up at all, don’t panic — the next section covers the most common culprits.
Troubleshooting Common Pitfalls
1. “My OLED display is blank” — wait, wrong tutorial
That’s a classic when you mix up I²C addresses, but we don’t have an OLED today. Still, if you’re coming from a display‑based project like the home temperature monitor, the same logic applies: check wiring, especially VCC and GND. For our setup, the equivalent is “My LED stays dark.”
2. LED doesn’t light / stays a dull white
- Resistors missing or too high: Without current limiting, you might have already damaged the LED. If you used 10kΩ instead of 220 Ω, the current is too small.
- Common cathode or anode mix‑up: If you accidentally wired a common‑anode LED, you’ll see nothing because the cathode side expects a path to ground. Swap the common pin to 3.3 V and invert the logic in ESPHome (set
rgb_light’s internal flag or rewrite the lambda to subtract from 1.0). Even easier: replace the LED with a common‑cathode one. - GPIO mis‑match: Double‑check that the YAML pins match the breadboard. A silent mismatch gives you no visual feedback.
3. ADC readings are NaN or stuck at 3.3 V
- Potentiometer not connected properly: If the wiper is floating, the ADC pin can pick up random noise and then suddenly read full scale. A sliding average might make it look “stuck.”
- Attenuation not set or set too low: With the default 0 db attenuation, the ESP32 can only measure up to ~1.1 V, so anything above that reads as 1.0 (full scale). Set
attenuation: 11dbas shown.
4. Upload fails with “A fatal error occurred: Failed to connect to ESP32”
- Use a data USB cable. Many micro‑USB cables are charge‑only and lack the data lines. If your computer doesn’t see a serial port, try a different cable.
- Hold the BOOT button (on some dev boards) while plugging in, then release after the uploader starts. This forces the ESP32 into download mode.
- Serial port permissions on Linux:
sudo chmod a+rw /dev/ttyUSB0or add yourself todialout.
5. Color jumps erratically, even when I don’t touch the knob
- Electrical noise from Wi‑Fi can couple into the ADC. Add a 100 nF capacitor between the wiper and ground — it acts as a low‑pass filter. I learned this trick after my “steady pink” turned into a stroboscopic nightmare.
- Increase the sliding window size in the filter (e.g., 10 samples) or decrease the update interval.
Enhancements & Next Steps
Once you’ve got the basics, the same hardware can evolve into a smart lamp that integrates with your home automation. Here are a few directions I’ve explored myself:
- Wireless control via Home Assistant: Since the light entity is exposed to the API, you can create automations that change the color based on a doorbell press or a motion sensor. For a taste of what that looks like with alerts, check out my Grafana alerting custom message tutorial — the principles transfer directly.
- Add a second potentiometer for brightness. Wire another 10k pot to GPIO35 and add a second ADC sensor. In the lambda, scale the RGB values by the brightness factor before calling
set_rgb. - Upgrade to an addressable RGB LED strip. Replace the discrete LED with a WS2812B strip controlled via a single GPIO. ESPHome’s
fastled_clocklesslight platform handles it beautifully, and you can keep the potentiometer as a physical dimmer/color wheel. - Deep sleep for battery operation. If you want a portable edition, add
deep_sleepcomponent that wakes only when the ADC value changes significantly. The ESP32’s ULP can monitor the pin while the main cores sleep — a neat rabbit hole if you’re into power efficiency. - Combine with a weather station. Now that you’ve mastered PWM, you could merge this with a sensor‑based project. The same ESP32 can read a BME280 and simultaneously drive an RGB LED that indicates, say, temperature trends (blue=cold, red=hot). The ESP32 weather station guide provides a solid foundation for the sensor half.
FAQ
Can I use a different potentiometer value, like 50k?
Absolutely. The ADC measures voltage, not resistance, so the exact value isn’t critical. A 10k potentiometer is common because it offers a good balance between current draw and noise immunity. With a 50k pot, the wiper voltage still swings 0–3.3 V, but you might see slightly more noise — the capacitor filter trick becomes even more useful.
Why is my RGB LED only showing red and green, no blue?
Check the blue channel’s resistor and connection. It’s possible that GPIO27 isn’t outputting PWM. Swap the LED channels temporarily — if blue doesn’t light on another GPIO either, the LED’s blue segment may be dead. Also verify that you haven’t accidentally mapped blue: GPIO27 to the wrong pin in YAML.
Can I use a DHT22 instead of a potentiometer? That seems unrelated…
You could map temperature to a color, but the DHT22 is a temperature/humidity sensor, not an analog input. This project is about learning PWM and ADC interaction. If you want to react to temperature, check out the ESP32 weather station tutorial and then come back here to add an indicator light — two great projects that work together.
Do I need an external power supply for the RGB LED?
For a single 5mm RGB LED, the ESP32’s 3.3 V rail (or even a GPIO’s limited current through 220 Ω resistors) is fine. The total current with all three anodes fully on is about (3.3 V / 220 Ω) × 3 ≈ 45 mA, well within the board’s capability. If you later switch to a high‑power LED strip, that’s a different story — you’ll need a separate power supply and logic‑level MOSFETs.
My ESPHome node shows the light entity, but switching it from Home Assistant doesn’t override the potentiometer — why?
That’s by design in our YAML. The potentiometer’s on_value automation continuously updates the light, so any manual change from HA will be overwritten with the next ADC reading (every 100 ms). If you want dual control, you need to add a condition — for example, only update when a “manual override” switch is off. That’s a fun project extension and a perfect excuse to dig deeper into ESPHome’s scripting.
I accidentally set attenuation to auto — is that a problem?
auto tries to select the best attenuation for the measured voltage, but it can cause jumps during the measurement cycle. For a potentiometer that consistently sees up to 3.3 V, fixed 11db is more stable. If you’re curious about the internal ADC behavior, the Espressif ADC documentation is the authoritative source.
With a handful of components and a single potentiometer, you’ve turned a bare ESP32 into a tangible color mixer. More importantly, you’ve seen how PWM and ADC work hand‑in‑hand — a skill you’ll reuse every time you dim an LED, control a servo, or read a joystick. The same YAML can be adapted to mood lamps, notification indicators, or even a sunrise alarm clock. The only limit is the time you’re willing to spend twisting that knob and watching the colors flow.