Learning Goals 5 min
Cluster F's capstone: a real "smart lamp" you can toggle from any phone on your home WiFi. The control surface is a phone-friendly webpage. The lamp itself is a low-voltage LED today (we'll talk about real mains separately — never wire 230 V in class). By the end of this lesson you will:
- Combine yesterday's sensor dashboard with L03-32's control endpoints into a single mobile-friendly UI: button to turn on/off, slider for brightness, live state read-back.
- Add "timer" commands — turn the lamp on for N minutes, then auto-off — using non-blocking timing.
- Discuss (not wire) how to scale this from a 20 mA LED to a 230 V household lamp using a relay module and proper electrical safety.
Warm-Up 10 min
Hardware for today is minimal: one LED + 220 Ω resistor on a PWM pin. (D5 on NodeMCU = GPIO14 = PWM-capable.) Wire as L03-32.
What we mean by "lamp"
For the school build, the "lamp" is a single LED. The architecture — phone UI sends commands; ESP toggles a pin — is identical to a real smart bulb. Only the load changes:
| Load | Driver | Safety |
|---|---|---|
| LED + 220 Ω | Direct from GPIO | None needed (3.3 V, < 20 mA) |
| Small DC motor / 12 V LED strip | MOSFET or driver IC | Power supply isolation |
| 230 V mains bulb | Optoisolated relay module | Wired by adult; behind a fuse; inside a sealed enclosure |
Never wire 230 V mains in a classroom project. The discussion is about how the architecture scales, not about doing it yourself. For a real smart-home build, buy a commercial smart plug or use a low-voltage path.
New Concept · Combined dashboard + control 25 min
What we're building
One HTML page with:
- Big "ON / OFF" toggle.
- Brightness slider (0–255).
- "Timer" quick-buttons: 1 min / 5 min / 15 min.
- Live state read-back so multiple phones stay in sync.
The endpoints
| Method | Path | Args | Effect |
|---|---|---|---|
| GET | / | — | HTML control page |
| GET | /state.json | — | Returns {on, brightness, secondsLeft} |
| POST | /on | — | Turn on at last brightness; clear timer |
| POST | /off | — | Turn off; clear timer |
| POST | /bright | ?value=N | Set brightness; if N==0 also turn off |
| POST | /timer | ?mins=N | Turn on, schedule auto-off after N min |
Why POST for the actions and GET for reads
HTTP convention: GET = safe / idempotent / read-only. POST = causes a state change. Following this means a browser's "refresh" on a state page won't accidentally trigger another command. Tools like cURL, scripts, and home-automation hubs also expect this convention.
The basic ESP8266WebServer handles GET and POST identically by default. To distinguish, use the three-arg form: server.on("/on", HTTP_POST, handler).
Non-blocking auto-off timer
unsigned long autoOffAt = 0; // 0 = no timer
void scheduleAutoOff(unsigned long mins) {
autoOffAt = millis() + mins * 60UL * 1000UL;
}
void clearAutoOff() {
autoOffAt = 0;
}
void loop() {
server.handleClient();
if (autoOffAt && (long)(millis() - autoOffAt) >= 0) {
digitalWrite(LED_PIN, LOW);
autoOffAt = 0;
Serial.println("# Auto-off fired");
}
}The (long)(millis() - autoOffAt) >= 0 cast handles the (unlikely-in-this-app) millis() overflow correctly. millis() overflows after ~49 days.
State management — one struct of truth
struct LampState {
bool on;
int brightness; // 0..255
unsigned long autoOffAt;
};
LampState lamp = {false, 128, 0};
void applyLamp() {
analogWrite(LED_PIN, lamp.on ? lamp.brightness : 0);
}All handlers mutate lamp then call applyLamp(). /state.json serialises lamp. Single source of truth — no risk of the actual LED disagreeing with what the API reports.
Worked Example · The smart lamp 25 min
Step 1 — wire one LED to D5 (PWM)
Same as L03-32 / L03-33.
Step 2 — the sketch
// L03-34 · WiFi-Controlled Lamp
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
#elif defined(ESP32)
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
#endif
const char* SSID = "YourNetwork";
const char* PASSWORD = "YourPassword";
const int LED_PIN = 14;
struct LampState {
bool on;
int brightness;
unsigned long autoOffAt;
};
LampState lamp = {false, 128, 0};
void applyLamp() {
analogWrite(LED_PIN, lamp.on ? lamp.brightness : 0);
}
const char index_html[] PROGMEM = R"HTML(
<!doctype html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Smart Lamp</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, sans-serif; max-width: 380px; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.4rem; }
.row { display: flex; gap: .5rem; margin: .8rem 0; }
button { flex: 1; padding: 1rem; font-size: 1rem; border: 0; border-radius: 8px; background: #4f46e5; color: #fff; }
button.off { background: #6b7280; }
input[type=range] { width: 100%; }
.state { font-size: 1.1rem; margin: .8rem 0; padding: .8rem; background: #f1f5f9; border-radius: 8px; }
</style></head><body>
<h1>Smart Lamp</h1>
<div class="state" id="state">--</div>
<div class="row">
<button onclick="post('/on')">ON</button>
<button class="off" onclick="post('/off')">OFF</button>
</div>
<label>Brightness <span id="brval">128</span></label>
<input type="range" min="0" max="255" value="128" id="br"
oninput="document.getElementById('brval').textContent = this.value"
onchange="post('/bright?value=' + this.value)">
<div class="row">
<button onclick="post('/timer?mins=1')">1 min</button>
<button onclick="post('/timer?mins=5')">5 min</button>
<button onclick="post('/timer?mins=15')">15 min</button>
</div>
<script>
async function post(p) { await fetch(p, { method: 'POST' }); refresh(); }
async function refresh() {
try {
const r = await fetch('/state.json');
const j = await r.json();
const t = j.secondsLeft > 0
? ' (auto-off in ' + j.secondsLeft + 's)'
: '';
document.getElementById('state').textContent =
(j.on ? 'ON' : 'OFF') + ' @ ' + j.brightness + t;
document.getElementById('br').value = j.brightness;
document.getElementById('brval').textContent = j.brightness;
} catch (e) { /* offline */ }
}
setInterval(refresh, 1000);
refresh();
</script>
</body></html>
)HTML";
void handleRoot() {
server.send_P(200, "text/html", index_html);
}
void handleState() {
String json = "{\"on\":";
json += (lamp.on ? "true" : "false");
json += ",\"brightness\":";
json += lamp.brightness;
json += ",\"secondsLeft\":";
if (lamp.autoOffAt) {
long remaining = (long)(lamp.autoOffAt - millis()) / 1000;
if (remaining < 0) remaining = 0;
json += remaining;
} else json += 0;
json += "}";
server.send(200, "application/json", json);
}
void handleOn() { lamp.on = true; lamp.autoOffAt = 0; applyLamp(); server.send(204); }
void handleOff() { lamp.on = false; lamp.autoOffAt = 0; applyLamp(); server.send(204); }
void handleBright() {
int v = constrain(server.arg("value").toInt(), 0, 255);
lamp.brightness = v;
if (v == 0) lamp.on = false;
else if (!lamp.on) lamp.on = true;
applyLamp();
server.send(204);
}
void handleTimer() {
int m = constrain(server.arg("mins").toInt(), 1, 60);
lamp.on = true;
lamp.autoOffAt = millis() + (unsigned long)m * 60UL * 1000UL;
applyLamp();
server.send(204);
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
applyLamp();
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println();
Serial.print("# Open http://"); Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/state.json", handleState);
server.on("/on", HTTP_POST, handleOn);
server.on("/off", HTTP_POST, handleOff);
server.on("/bright", HTTP_POST, handleBright);
server.on("/timer", HTTP_POST, handleTimer);
server.begin();
}
void loop() {
server.handleClient();
// Auto-off check
if (lamp.autoOffAt && (long)(millis() - lamp.autoOffAt) >= 0) {
lamp.on = false;
lamp.autoOffAt = 0;
applyLamp();
Serial.println("# Auto-off fired");
}
}Step 3 — upload and open from your phone
- Phone on the same WiFi → open
http://<esp-IP>/. - Big "Smart Lamp" page appears.
- Tap ON. LED lights up. State updates to "ON @ 128".
- Drag slider. Brightness changes live (releases of the slider trigger the POST; the on-input event updates the on-screen number).
- Tap "1 min". LED lights up; state shows "ON @ 128 (auto-off in 60s)"; countdown updates every second.
- Wait 60 s — LED switches off; state shows "OFF @ 128".
Step 4 — open on two phones at once
Two family members can both have the page open. When one toggles, the other sees the state update within 1 s (refresh interval). Multi-user smart-home control.
Step 5 — scaling to a real lamp (talk only, no wiring)
To control a real 230 V household bulb you'd add an opto-isolated relay module between the ESP's GPIO and the mains-side switch. The module electrically isolates the mains from the microcontroller; the GPIO never sees mains voltage. Hardware:
- A 5 V opto-isolated relay module (cheap, sold as "ESP relay" or "Arduino relay").
- Wire the ESP GPIO + 5 V + GND to the relay's low side.
- The relay's high side (COM + NO contacts) goes in series with the lamp's live wire — done by a qualified electrician inside a sealed plastic enclosure.
Do not do this without adult supervision and proper enclosure. A loose live wire is a fatal hazard. The architecture is the same as this lesson's LED; only the load and the safety bar change.
For a no-electrician build: buy a Sonoff smart plug, flash Tasmota or ESPHome, expose HTTP endpoints — the work is identical to this lesson but the manufacturer already did the mains-safety engineering.
Step 6 — bookmark on your phone's home screen
Both iOS and Android let you "Add to Home Screen" from the browser. Your lamp page becomes a one-tap launchable icon — feels like a native app, runs entirely off your ESP's tiny web server.
Try It Yourself 15 min
Goal: Add a "Cancel timer" button that clears the auto-off without turning off the lamp.
Hint
server.on("/cancel-timer", HTTP_POST, []() {
lamp.autoOffAt = 0;
server.send(204);
});Add a button on the page. The state.json already reports secondsLeft; the UI just shows it.
Goal: Save the lamp's last state (on/off and brightness) to flash so it survives a reboot. On boot, restore the saved state.
Hint
Use the EEPROM library — read/write to addresses 0..3. Persist on every state change. On boot, read addresses 0..3 and apply.
#include <EEPROM.h>
void saveState() {
EEPROM.write(0, lamp.on ? 1 : 0);
EEPROM.write(1, lamp.brightness);
EEPROM.commit();
}
// in setup, before WiFi:
EEPROM.begin(8);
lamp.on = EEPROM.read(0) == 1;
lamp.brightness = EEPROM.read(1);
if (lamp.brightness == 0) lamp.brightness = 128;
applyLamp();Beware: flash has limited write cycles (~100,000). For something that changes often, don't save every change — debounce / save only after settling.
Goal: Add a daily schedule. The lamp turns on at 7 PM and off at 11 PM. Needs the current time — preview of L03-35 (NTP).
Hint
Use configTime(0, 0, "pool.ntp.org") (built into both ESP cores) and time(nullptr) + localtime_r to get the current hour. In loop(), compare to the schedule and toggle. Tomorrow's lesson dives into this properly.
Mini-Challenge · Ship the smart lamp 10 min
- Put the ESP + LED + wiring inside a small clear-plastic enclosure (a takeaway box works). Cable management.
- Print a sticker: device name + URL (
http://192.168.1.42). Stick it on the box. - Verify the home-screen icon works on your phone.
- Hand the phone to a parent / sibling. Can they toggle the lamp without instruction?
- Take a 30-second video: phone, page, lamp on, brightness slider, 1-min timer countdown.
Ship-ready test: if a non-coder can turn it on from across the room, set a 5-minute timer, and walk away knowing it'll go off — you've shipped a real smart-home device.
Cluster F is done. Cluster G (Time and Internet Glue) starts tomorrow — short cluster that gives your device a real-world clock and the ability to push notifications elsewhere.
Recap 5 min
Smart lamp = ESP web server + HTML control page + a few POST endpoints + one struct of state + a non-blocking auto-off timer. The architecture is exactly the same as commercial smart bulbs (the cloud part is added on top by manufacturers but the device itself works fine as a LAN-only device). For real mains loads, opt for a commercial smart plug or work with a qualified electrician — never wire 230 V yourself. Cluster G next: real time-of-day clocks via NTP and the "send a Discord message when something happens" webhooks pattern.
- POST vs GET
- Conventions for HTTP methods. GET = read, no side effects, safe to refresh. POST = state change. Following the convention makes scripts and integrations work as expected.
- HTTP 204 No Content
- Success response with no body. Used when the POST succeeded but there's nothing meaningful to return.
- State of truth
- The single in-memory struct that represents the current state. All handlers mutate it; all reads serialise it. Avoids divergence between hardware and reported state.
- Auto-off timer
- A future timestamp;
loop()checks each iteration whethermillis()has passed it. Non-blocking; cooperates with everything else in the loop. - Opto-isolator
- A device that uses an LED and a phototransistor to pass signals between electrically isolated circuits. Used in relay modules so mains can never reach the microcontroller.
- Relay module
- An opto-isolated relay on a breakout PCB with a logic-level input. The standard way to switch mains from a microcontroller — but must be installed by a qualified electrician inside a sealed enclosure.
- Add to Home Screen
- Browser feature on iOS / Android that creates a launchable icon for a webpage. Makes your ESP's page feel like a native app.
- Smart bulb / smart plug architecture
- Microcontroller + WiFi + a switching circuit + a phone app. Commercial products add a cloud layer for remote access; LAN-only devices skip the cloud.
Homework 5 min
- Build the smart lamp. Video a 30-second demo (page + lamp).
- Save the sketch as
esp-lamp.ino. - Read ahead to ARD-L03-35 (NTP Time Sync). Tomorrow we give the device a real clock.
Bring back next class:
- Working lamp + video.
- ESP still wired (will use for L03-35).