PROJECT 6 · WI‑FI / WEB

RGB LED Web Server: Color Picker

Pick any color in the browser; the ESP32 mixes it on an RGB LED with three PWM outputs.

Builds on: Project 3's PWM and Project 5's web server, combined rather than replaced.

⏱ ~30 min 📊 Intermediate 🔖 Prerequisite: Project 3 & 5
⬇ Download the sketch Save Project_6_RGB_LED_Web_Server.ino, then open it in the Arduino IDE (setup: Foundation 3). Remember to set your Wi‑Fi credentials.
Color picker sends r/g/b to the ESP32 which drives the RGB LED
Browser color picker → r/g/b values → ESP32 → PWM to the RGB LED.
🎲 Try it without hardware 📄 diagram.json Open the Wokwi simulator, then open or download diagram.json above and paste its wiring in to load the circuit.

🎯 Objective

By the end of this project, you will:

  • Understand how one RGB LED package holds three separate LEDs, and what "common cathode" means for wiring.
  • Mix any colour by setting three PWM duty cycles, the same additive mixing a screen uses.
  • Drive three PWM outputs at once with the Core v3.x+ ledcAttach() API.
  • Parse values out of a URL, extracting three numbers from a request like /?r201g32b255&.
  • Combine two earlier projects: Project 3's PWM and Project 5's web server.

1How an RGB LED works

An RGB LED is three LEDs in one. The kit's are common cathode: all three share one negative leg, each color has its own anode:

Common-cathode RGB LED pinout
One shared cathode (−), three anodes (R, G, B).

Setting each color's brightness with PWM mixes new colors:

R
G
B
R+G
G+B
R+B
R+G+B
RGB additive color mixing chart
Additive mixing: overlapping colors make new ones; all three = white.
If your RGB LED doesn't behave like this: some RGB LEDs are common anode instead: the shared leg is the + side, going to 3.3 V, and each color's leg is switched toward GND instead of driven HIGH. Wiring mirrors (shared leg to 3.3 V, not GND) and the code inverts too: ledcWrite(pin, 255 - value) instead of ledcWrite(pin, value), since a low duty cycle is now what lights the LED. This kit's RGB LEDs are common cathode, so you won't need this unless you're using a different module.
⚠️ Common misconception: assuming "RGB LED" means one wiring and one line of code. Common‑cathode and common‑anode modules look identical from the outside (four legs, one longer) but need opposite wiring and inverted code. Plugging a common‑anode LED in as if it were common‑cathode doesn't damage it, it just makes the colors look backwards: full brightness reads as off and vice versa.

2How the picker talks to the ESP32

Pressing Change Color requests a URL that encodes the color:

/?r201g32b255&

The ESP32 finds the r, g, b, & markers, slices out each number, and writes it to the matching PWM pin:

redString   = header.substring(pos1+1, pos2);   // "201"
ledcWrite(redPin, redString.toInt());           // 0..255
Internet note: the picker page loads Bootstrap + jsColor from the internet, so the device viewing the page needs internet the first time. The ESP32 itself only needs your local Wi‑Fi.

Setting up three PWM outputs

This is Project 3's PWM done three times, once per color. Each pin gets one ledcAttach() call in setup():

const int freq = 5000;        // 5 kHz, far too fast for the eye to see
const int resolution = 8;     // 8 bits -> 256 levels per color (0..255)

ledcAttach(redPin,   freq, resolution);   // GPIO 13
ledcAttach(greenPin, freq, resolution);   // GPIO 12
ledcAttach(bluePin,  freq, resolution);   // GPIO 14
ESP32 Core v3.x+ API: ledcAttach(pin, freq, resolution) does the whole setup in one call, and ledcWrite() takes the GPIO pin as its first argument. The core picks a free internal PWM channel per pin, so you never name a channel yourself. Older Core v2.x code used ledcSetup() + ledcAttachPin() and wrote to a channel number; those functions no longer exist in v3.x+.

8‑bit resolution is what makes the color picker line up so neatly: it hands you 0 to 255 per color, one duty‑cycle step per level, giving 256³ ≈ 16.7 million colors.

3Parts & wiring

QtyPartNotes
1ESP32 · breadboard · jumper wires-
1RGB LED (common cathode)4 legs
3220 Ω resistorOne per color
RGB LED wiring: R to GPIO 13, G to GPIO 12, B to GPIO 14, cathode to GND
R → GPIO 13 · G → GPIO 12 · B → GPIO 14 · common cathode → GND (each anode via 220 Ω).

4Upload & use

Set your Wi‑Fi ssid/password (2.4 GHz), upload, open Serial Monitor at 115200, press EN/RESET:

==============================================
 Project 6: ESP32 RGB LED Web Server
==============================================
RGB LED -> R:GPIO13  G:GPIO12  B:GPIO14
Connecting to Wi-Fi: MyNetwork
....
Wi-Fi connected!
Open this address in your browser:  http://192.168.1.42
----------------------------------------------
[WEB] Color set -> R:201  G:32  B:255

Open the address, pick a color, press Change Color. The log prints the exact R/G/B applied (added to the original sketch).

5Code Walkthrough: Understanding the Sketch

This is Project 5's web server with two changes: the outputs are PWM instead of on/off, and the request carries three numbers instead of naming a fixed action. Here are the parts that are new.

Step 1: Pins, PWM settings, and the strings that hold the colour

const int redPin   = 13;   // 13 corresponds to GPIO13
const int greenPin = 12;
const int bluePin  = 14;

const int freq = 5000;     // 5 kHz PWM
const int resolution = 8;  // 8 bits -> 0..255 per colour

String redString = "0", greenString = "0", blueString = "0";

The colours are kept as String rather than numbers because that is how they arrive: as text sliced out of the URL. They become integers only when written to the LED.

Step 2: three PWM channels plus Wi-Fi in setup()

ledcAttach(redPin,   freq, resolution);   // GPIO 13
ledcAttach(greenPin, freq, resolution);   // GPIO 12
ledcAttach(bluePin,  freq, resolution);   // GPIO 14

WiFi.begin(ssid, password);
server.begin();

Section 2's PWM setup followed by Project 5's Wi-Fi setup, unchanged. Nothing about hosting a page interferes with PWM: the LED PWM controller is hardware and keeps running while the processor handles requests.

Step 3: loop() parses the colour out of the URL

The HTTP skeleton (accept a client, read bytes, wait for the blank line, reply) is exactly Project 5's. The new part is what happens once a request has arrived:

// Request sample: /?r201g32b255&   ->  Red=201 Green=32 Blue=255
if (header.indexOf("GET /?r") >= 0) {
  pos1 = header.indexOf('r');       // find each marker
  pos2 = header.indexOf('g');
  pos3 = header.indexOf('b');
  pos4 = header.indexOf('&');

  redString   = header.substring(pos1 + 1, pos2);   // "201"
  greenString = header.substring(pos2 + 1, pos3);   // "32"
  blueString  = header.substring(pos3 + 1, pos4);   // "255"

  ledcWrite(redPin,   redString.toInt());
  ledcWrite(greenPin, greenString.toInt());
  ledcWrite(bluePin,  blueString.toInt());
}

The letters r, g, b and the final & are delimiters. Each number is whatever sits between one marker and the next, so substring(pos1 + 1, pos2) means "from just after the r up to the g". A crude hand-rolled parser, but it makes the mechanism visible in a way a library call would not.

The page is sent by the same client.println() calls as Project 5. It loads the jsColor picker, whose onFineChange handler rewrites the link's address as you drag, building the ?r…g…b…& URL that the code above takes apart.

Key concepts: what each line really does

CodeWhat it does
ledcAttach(pin, freq, resolution)Enables PWM on one GPIO. Called three times, once per colour, so all three dim independently.
ledcWrite(pin, value)Sets one colour's brightness, 0 to 255 at 8-bit resolution. Three together make the mixed colour.
header.indexOf('r')Finds the position of a delimiter in the request text, or -1 if missing.
header.substring(from, to)Copies the characters between two positions: the digits of one colour value.
redString.toInt()Converts the text "201" into the number 201 that ledcWrite() needs.
indexOf("GET /?r") >= 0Tells a colour request apart from the browser's plain first visit, so the LED is only rewritten when a colour was sent.

Going further: gamma correction (why the slider doesn't look linear)

Doubling the PWM duty cycle does not double how bright the LED looks. Human eyes perceive brightness on a roughly logarithmic curve, so raising a channel from 10 to 20 (out of 255) looks like a big jump, while 200 to 210 looks almost unchanged, even though both moved the same 10 steps. That's why a 0-to-255 slider feels bunched up at the bright end.

Gamma correction fixes this by remapping each value before you write it:

uint8_t gammaCorrect(uint8_t value) {
  return (uint8_t)(pow(value / 255.0, 2.8) * 255.0 + 0.5);
}
// ...
ledcWrite(redPin, gammaCorrect(redString.toInt()));

Raising the input to a power (a common choice is 2.8) compresses the low end and stretches the high end, so equal steps in the slider look like more equal steps in brightness. Screens, camera flashes, and stage lighting all apply the same correction, for the same reason: your eye's response to light isn't the one the PWM hardware assumes.

Predict, then check. Predict: what color shows if you set R and G to full and leave B at zero? Pick that combination on the color wheel and see if you were right.

6Demonstration

Animated demo: color picker cycles through red, green, blue, yellow, and more
Saturated colors look best. Pick black to turn the LED off.

🎯 Knowledge Check

1. In a common-cathode RGB LED, where does the shared leg connect?

2. At 8-bit PWM resolution, how many colors can an RGB LED produce?

3. What does the URL parameter `/?r201g32b255&` mean?

4. Why does doubling the PWM duty cycle not double the perceived brightness?

5. What is the purpose of `ledcAttach(pin, freq, resolution)` in the Core v3.x+ API?

8Wrapping Up: What You've Learned

This project combines two earlier ones rather than teaching something entirely new, which is how most real devices get built.

  • An RGB LED is three LEDs sharing one leg. On a common-cathode part that leg goes to GND and each colour gets its own resistor and PWM pin.
  • Colour mixing is additive: red plus green makes yellow, all three at full make white, all off is darkness. Your eye does the blending.
  • 8-bit resolution is not arbitrary: 0 to 255 per channel is exactly what the browser's colour picker produces, so the numbers pass through untouched.
  • A URL can carry data, not just a command. Projects 5 and 7 name an action in the path; here the path carries three values.
  • indexOf() and substring() are enough to pull structured data out of text. Later projects hand this to a library (ArduinoJson in Project 12), but the idea is the same.
  • Hardware PWM runs independently of your code: the LED holds its colour while the processor serves pages.
  • Why we switch to a web-server library now. Projects 5 and 6 hand-rolled HTTP so you could see what a request and response actually look like as text. That parsing does not change project to project, so from Project 7 on the course switches to the ESP Async WebServer library: it handles the parsing for you, matches a URL to a handler you register, and answers many clients without blocking loop(), the same non-blocking idea millis() introduced in Project 4.

Now try

  • Color cycle animation: Add a mode that automatically fades through red, green, blue, yellow, cyan, magenta, and white with a button to start/stop it.
  • Hex color input: Add a text field on the web page where you can type a hex color code (like #FF8800) and have the ESP32 parse it and set the LED.

9Troubleshooting

SymptomLikely causeFix
Page loads, no pickerNo internet on the phone (CDN blocked)Give the viewing device internet the first time
Only some colors workA color leg mis‑wiredCheck R/G/B → GPIO 13/12/14
Colors inverted / oddCommon‑anode LEDThis kit uses common‑cathode → shared leg to GND
Stuck on "Connecting…"Wrong credentials / 5 GHzFix credentials; use 2.4 GHz
'ledcAttach' was not declaredESP32 Core v2.x installedUpdate esp32 by Espressif Systems to v3.x+ in Boards Manager