How often is a light left on in an empty room? Lights, fans and air conditioners that run for nobody waste energy, money, and the electricity that power stations have to produce. Offices and schools use motion sensors to switch things off automatically. In this capstone you build one with the ESP32 and add something most switches don't have: it counts what it saves. A PIR sensor watches the room. While people move, the "room light" stays on. When nobody has moved for a few minutes, a yellow light blinks and the buzzer chirps: wave if you are still there! Then the light switches off. A web page on your phone shows the light, the countdown, the energy used and saved, and what that means in money. You can change the switch-off time, the lamp's power and your electricity price on the page, and the ESP32 remembers them.
What you'll learn
- How a timeout with a warning works, so the light never switches off on someone sitting still
- How to calculate energy: power × time, in watt-hours
- How the ESP32 serves a web page and answers with JSON
- How to save settings in the ESP32's flash memory with Preferences
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- PIR motion sensor (ESP32 IoT Starter Kit)
- White LED and 1 resistor, 220 Ω, as the room light (ESP32 IoT Starter Kit)
- Yellow LED and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
- Passive buzzer (ESP32 IoT Starter Kit)
- A phone or computer on the same Wi-Fi
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Wiring

| From | To |
|---|---|
| PIR VCC | ESP32 5V |
| PIR OUT | ESP32 GPIO 27 |
| PIR GND | GND |
| ESP32 GPIO 25 | 220 Ω resistor, then the white LED's long leg (the "room light") |
| ESP32 GPIO 26 | 220 Ω resistor, then the yellow LED's long leg (the warning light) |
| LEDs' short legs | GND |
| Passive buzzer + | ESP32 GPIO 33 |
| Passive buzzer − | GND |
The PIR sensor needs 5 V for power, but its OUT pin only gives 3.3 V, which is safe for the ESP32. Turn its time knob to the minimum (fully anticlockwise): the sketch does the timing itself.
The sketch
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
WebServer, ESPmDNS and Preferences come with the ESP32 board package.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int PIR_PIN = 27, LIGHT_PIN = 25, WARN_PIN = 26, BUZZER_PIN = 33;
const unsigned long WARN_BEFORE = 30000; // warn 30 s before switching off
const unsigned long MAX_SAVING = 12UL * 3600000; // count at most 12 h saved per switch-off
enum Mode { AUTO, ALWAYS_ON, ALWAYS_OFF };
Mode mode = AUTO;
int timeoutMin = 5; // switch off after this many minutes without movement
int lampWatts = 60; // the lamp you want to save on
float price = 0.25; // price of 1 kWh in your currency
WebServer server(80);
Preferences prefs;
bool lightOn = false, warned = false, autoOff = false;
unsigned long lastMotion = 0, onSince = 0, offSince = 0;
double usedWh = 0, savedWh = 0; // energy used and saved so far
int autoOffs = 0;
const char PAGE[] PROGMEM = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Energy saver</title>
<style>body{font-family:Arial,sans-serif;background:#F6F8FC;color:#1B3A7B;margin:0 auto;padding:16px;max-width:480px}
.card{background:#fff;border-radius:16px;padding:16px;margin-bottom:14px;box-shadow:0 2px 8px rgba(0,0,0,.08)}
.big{font-size:32px;font-weight:bold}.on{color:#D99A00}.off{color:#6B7280}
button{font-size:17px;padding:10px 14px;border-radius:10px;border:0;margin:4px 4px 0 0;background:#E5E7EB}
button.sel{background:#1B3A7B;color:#fff}input{font-size:17px;width:90px;padding:6px}td{padding:5px 12px 5px 0}</style></head><body>
<h1>Energy saver</h1>
<div class="card"><div id="light" class="big">...</div><p id="timer"></p>
<button id="m0" onclick="setMode(0)">Auto</button><button id="m1" onclick="setMode(1)">Always on</button><button id="m2" onclick="setMode(2)">Always off</button></div>
<div class="card"><table><tr><td>Energy used</td><td id="used"></td></tr><tr><td>Energy saved</td><td id="saved"></td></tr>
<tr><td>Money saved</td><td id="money"></td></tr><tr><td>Switched off for you</td><td id="offs"></td></tr></table></div>
<div class="card"><p>Switch off after <input id="timeout" type="number" min="1" max="60"> min</p>
<p>Lamp power <input id="watts" type="number" min="1" max="3000"> W</p>
<p>Price of 1 kWh <input id="price" type="number" min="0" step="0.01"></p><button onclick="save()">Save</button></div>
<script>
var first = true;
function setMode(m) { fetch('/mode?m=' + m).then(poll); }
function save() {
var q = ['timeout', 'watts', 'price'].map(function (k) { return k + '=' + document.getElementById(k).value; }).join('&');
fetch('/set?' + q).then(poll);
}
function mmss(s) { return Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2); }
function poll() {
fetch('/state').then(function (r) { return r.json(); }).then(function (s) {
var l = document.getElementById('light');
l.textContent = s.on ? 'Light ON' : 'Light OFF';
l.className = 'big ' + (s.on ? 'on' : 'off');
document.getElementById('timer').textContent = s.mode == 0 && s.on ? 'No movement for ' + mmss(s.still) + ' - off in ' + mmss(s.left) : '';
for (var i = 0; i < 3; i++) document.getElementById('m' + i).className = s.mode == i ? 'sel' : '';
document.getElementById('used').textContent = s.used.toFixed(1) + ' Wh';
document.getElementById('saved').textContent = s.saved.toFixed(1) + ' Wh';
document.getElementById('money').textContent = s.money.toFixed(3);
document.getElementById('offs').textContent = s.offs + ' times';
if (first) {
first = false;
['timeout', 'watts', 'price'].forEach(function (k) { document.getElementById(k).value = s[k]; });
}
});
}
poll();
setInterval(poll, 2000);
</script></body></html>)rawliteral";
double wattHours(unsigned long ms) { return ms / 3600000.0 * lampWatts; }
void switchLight(bool on, const char* why) {
unsigned long now = millis();
if (on == lightOn) return;
if (on) {
if (autoOff) savedWh += wattHours(min(now - offSince, MAX_SAVING)); // it would have been on all that time
autoOff = false;
onSince = now;
} else {
usedWh += wattHours(now - onSince);
}
lightOn = on;
warned = false;
Serial.printf("Light %s (%s)\n", on ? "ON" : "OFF", why);
}
void chirp() {
tone(BUZZER_PIN, 1760, 80);
delay(160);
tone(BUZZER_PIN, 1760, 80);
}
void handleState() {
unsigned long now = millis();
unsigned long still = (now - lastMotion) / 1000, limit = timeoutMin * 60UL;
double used = usedWh + (lightOn ? wattHours(now - onSince) : 0);
double saved = savedWh + (autoOff ? wattHours(min(now - offSince, MAX_SAVING)) : 0);
String j = "{\"on\":" + String(lightOn ? "true" : "false") + ",\"mode\":" + String((int)mode);
j += ",\"still\":" + String(still) + ",\"left\":" + String(still < limit ? limit - still : 0);
j += ",\"used\":" + String(used, 2) + ",\"saved\":" + String(saved, 2) + ",\"money\":" + String(saved / 1000 * price, 4);
j += ",\"offs\":" + String(autoOffs) + ",\"timeout\":" + String(timeoutMin) + ",\"watts\":" + String(lampWatts) + ",\"price\":" + String(price, 2) + "}";
server.send(200, "application/json", j);
}
void handleSet() {
if (server.hasArg("timeout")) timeoutMin = constrain(server.arg("timeout").toInt(), 1, 60);
if (server.hasArg("watts")) lampWatts = constrain(server.arg("watts").toInt(), 1, 3000);
if (server.hasArg("price")) price = constrain(server.arg("price").toFloat(), 0.0f, 100.0f);
prefs.putInt("timeout", timeoutMin); // saved in flash: kept after a restart
prefs.putInt("watts", lampWatts);
prefs.putFloat("price", price);
server.send(200, "text/plain", "ok");
}
void handleMode() {
mode = (Mode)constrain(server.arg("m").toInt(), 0, 2);
lastMotion = millis();
if (mode == ALWAYS_ON) switchLight(true, "always on");
if (mode == ALWAYS_OFF) {
switchLight(false, "always off");
autoOff = false; // switched off by hand: not a saving
}
server.send(200, "text/plain", "ok");
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
pinMode(LIGHT_PIN, OUTPUT);
pinMode(WARN_PIN, OUTPUT);
prefs.begin("energy", false);
timeoutMin = prefs.getInt("timeout", timeoutMin);
lampWatts = prefs.getInt("watts", lampWatts);
price = prefs.getFloat("price", price);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
MDNS.begin("energy"); // http://energy.local
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.on("/state", handleState);
server.on("/set", handleSet);
server.on("/mode", handleMode);
server.begin();
Serial.print("\nOpen http://energy.local or http://");
Serial.println(WiFi.localIP());
}
void loop() {
server.handleClient();
unsigned long now = millis();
if (mode == AUTO) {
if (digitalRead(PIR_PIN) == HIGH) {
lastMotion = now;
if (!lightOn) switchLight(true, "movement");
if (warned) {
warned = false;
Serial.println("Still here: timer restarted");
}
}
unsigned long limit = timeoutMin * 60000UL;
if (lightOn && !warned && now - lastMotion > limit - WARN_BEFORE) {
warned = true; // last chance: wave if you are there
chirp();
}
if (lightOn && now - lastMotion > limit) {
switchLight(false, "room empty");
autoOff = true;
offSince = now;
autoOffs++;
}
}
digitalWrite(LIGHT_PIN, lightOn);
digitalWrite(WARN_PIN, warned && (now / 300) % 2);
}
Try it
- Upload the sketch and open the Serial Monitor at 115200 baud. When it shows the address, open
http://energy.localon your phone (on the same Wi-Fi), or type the number it shows. - Wave at the PIR sensor: the white LED (the room light) comes on, and the page says "Light ON".
- For a quick test, set "Switch off after" to 1 minute and tap Save. Stay out of the sensor's view. After 30 seconds the yellow LED blinks and the buzzer chirps twice.
- Wave now, and the timer starts again. Or stay away, and 30 seconds later the light goes off. The page counts one switch-off.
- Wait a few minutes, then wave again: the light comes on and "Energy saved" goes up. Set the lamp power to 2000 W, like a heater or an air conditioner, and see how much more that saves.
How it works
Energy is power multiplied by time. A 60 W lamp that is on for one hour uses 60 watt-hours (Wh); 1000 Wh are one kilowatt-hour (kWh), the unit on electricity bills. wattHours() turns milliseconds into watt-hours: divide by 3,600,000 (the milliseconds in an hour) and multiply by the lamp's power. When the light switches off by itself, the sketch notes the time. When someone comes back, the time in between counts as saved, because without the saver the light would have been on all that time. To be fair, it counts at most 12 hours per switch-off.
A PIR sensor only sees movement. Someone reading quietly may not move enough, and switching the light off on them would be annoying. That is why the saver warns 30 seconds before the timeout: the yellow light and the chirp give people a chance to wave. Good automatic systems always give people a way to stay in control, here also with the "Always on" and "Always off" buttons.
The web page is stored in the sketch. Every two seconds it asks the ESP32 for /state and gets a short JSON text back with all the numbers. The settings are saved with Preferences, which writes them into the ESP32's flash memory, so they survive a restart. The energy counters live only in normal memory and start at zero after a restart; saving them every few minutes would be a good next step.
Try this next
- Switch a real low-voltage lamp with the relay module, as in the relay lesson. Never connect mains voltage.
- Add the photoresistor so the light only switches on when it is dark.
- Save the energy counters with Preferences every 10 minutes, and add a "Reset" button to the page.
- Count the energy per day and send a daily report to your phone with ntfy.
Troubleshooting
- The light switches off while you are in the room: the PIR sensor can't see you. Point it at where people sit, turn its sensitivity knob up, or set a longer switch-off time.
- The light never switches off: check that the PIR's time knob is at the minimum. Moving curtains, pets and heaters can also trigger it.
-
energy.local doesn't open: some phones don't understand
.localnames. Type the number shown in the Serial Monitor instead, withhttp://in front. - The settings don't change: tap Save after typing, and reload the page.
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.