An Introduction to PWM: Controlling RGB LED Colors with an ESP32 Potentiometer

April 14, 2025 · ESP32, PWM, RGB LED, potentiometer, ESPHome, ADC, tutorial, microcontroller, color control, home automation

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).

Optional sanity helpers:


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 PinComponent / PinNotes
GNDPotentiometer pin 1One outer leg
3.3 VPotentiometer pin 3The other outer leg
GPIO34Potentiometer pin 2Wiper (middle) — ADC1 channel 6
GNDRGB LED common cathodeLongest leg, or the leg that’s slightly apart
GPIO25Red anode (via 220 Ω)Connect resistor in series
GPIO26Green anode (via 220 Ω)
GPIO27Blue 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.

  1. Create a new device. From the ESPHome dashboard (or esphome wizard), define a new node. Name it something like rgb-pot. Choose “ESP32” as the board type.
  2. Copy the YAML configuration I’ll provide below into your node’s .yaml file. You can also start with a minimal template and add the sensor/light blocks manually.
  3. 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"
  1. 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 dialout group so you can flash without sudo.

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

  1. Connect the ESP32 via USB and hit “Upload” in the ESPHome dashboard (or esphome run rgb-pot.yaml).
  2. 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
    
  3. 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.
  4. 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 attenuation setting 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

3. ADC readings are NaN or stuck at 3.3 V

4. Upload fails with “A fatal error occurred: Failed to connect to ESP32”

5. Color jumps erratically, even when I don’t touch the knob


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:


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.