Build a lamp that learns when you want light

Switch a lamp on and off with a button for a few days, and it starts doing it for you. The ESP32 keeps a score for every half hour of the day, learns from what you do, and even learns...
  • Level:Intermediate
  • Duration: 1 h
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a lamp that learns when you want light – EEMENTOR STEM™ lesson

Smart thermostats and smart lights often say they "learn your habits". After a week they warm the house before you get up, or switch the hall light on just before you come home. How can a gadget learn without anyone programming the times into it? In this lesson you build a lamp that does exactly that. You switch it on and off with a button whenever you like. The ESP32 watches, keeps a score for every half hour of the day, and after a few days it starts switching the lamp on by itself at the times you usually want light. If it gets it wrong and you switch it off, it learns from that too. This kind of AI, which keeps learning a little from every new example, is called online learning.

What you'll learn

  • What "learning from habits" really means inside a smart device
  • How a score with a learning rate (an exponential moving average) remembers patterns and slowly forgets old ones
  • Why learning from mistakes matters, and how to give negative feedback
  • How to get the time from the internet with NTP
  • How to keep what the ESP32 has learned in flash memory with Preferences

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • White LED and 1 resistor, 220 Ω (the lamp)
  • Blue LED and 1 resistor, 220 Ω (shows when the lamp switched itself on)
  • 1 push button
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Build a lamp that learns when you want light
Breadboard view. Check each connection against the wiring table below.

Build it step by step

Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.

Step 1 of 3: GND from the ESP32 to the blue rail
Step 1. GND from the ESP32 to the blue rail
Step 2 of 3: Lamp LED on GPIO 25, "switched itself on" LED on GPIO 26
Step 2. Lamp LED on GPIO 25, "switched itself on" LED on GPIO 26
Step 3 of 3: Lamp button: GPIO 27 and GND
Step 3. Lamp button: GPIO 27 and GND
From To
ESP32 GPIO 25 220 Ω resistor, then the white LED's long leg
White LED short leg GND
ESP32 GPIO 26 220 Ω resistor, then the blue LED's long leg
Blue LED short leg GND
Push button, one leg ESP32 GPIO 27
Push button, the diagonally opposite leg GND
ESP32 GND Bottom − rail (all the GND connections above go to this rail)

The sketch

The sketch starts in demo mode: a whole day lasts 144 seconds, so each half hour takes 3 seconds and you can watch the lamp learn in a few minutes. Demo mode doesn't need Wi-Fi. When you are ready, set DEMO to false and fill in your Wi-Fi details, and the lamp uses the real time from the internet.

#include <WiFi.h>
#include <Preferences.h>
#include <time.h>

const bool DEMO = true;              // true: a whole day lasts 144 s (3 s per half hour)
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TIME_ZONE = "CET-1CEST,M3.5.0,M10.5.0/3";   // see "How it works"

const int LAMP_PIN = 25, AUTO_LED = 26, BUTTON_PIN = 27;
const int SLOTS = 48;                // 48 half hours in a day
const float RATE = 0.3;              // learning rate: how much one day counts
const float ON_ABOVE = 0.6;          // switch on by itself above this score
const float OFF_BELOW = 0.4;         // switch off by itself below this score

float score[SLOTS];                  // 0 = never wanted light, 1 = always wanted light
Preferences memory;
bool lampOn = false, autoOn = false, refused = false;
int slot = -1;
unsigned long onMsInSlot = 0, lampOnSince = 0, slotStarted = 0;

unsigned long slotLength() { return DEMO ? 3000UL : 1800000UL; }

int currentSlot() {
  if (DEMO) return (millis() / 3000UL) % SLOTS;
  struct tm t;
  if (!getLocalTime(&t, 10)) return -1;
  return t.tm_hour * 2 + t.tm_min / 30;
}

void printTime(int s) {
  int h = s / 2, m = (s % 2) * 30;
  if (h < 10) Serial.print('0');
  Serial.print(h);
  Serial.print(m == 0 ? ":00" : ":30");
}

void setLamp(bool on, bool byItself) {
  unsigned long now = millis();
  if (lampOn && !on) onMsInSlot += now - lampOnSince;
  if (!lampOn && on) lampOnSince = now;
  lampOn = on;
  autoOn = on && byItself;
  digitalWrite(LAMP_PIN, on);
  digitalWrite(AUTO_LED, autoOn);
}

// Called at the end of each half hour: learn from what happened in it.
void learn(int s) {
  unsigned long now = millis();
  unsigned long onMs = onMsInSlot + (lampOn ? now - lampOnSince : 0);
  float wanted = (onMs * 2 >= slotLength()) ? 1.0 : 0.0;   // on for at least half of it?
  if (refused) wanted = 0.0;                               // switched off after the lamp guessed
  score[s] += RATE * (wanted - score[s]);
  printTime(s);
  Serial.print("  light wanted: ");
  Serial.print(wanted > 0.5 ? "yes" : "no ");
  Serial.print("  new score ");
  Serial.print(score[s], 2);
  Serial.print("  ");
  for (int i = 0; i < score[s] * 20; i++) Serial.print('#');
  Serial.println();
  if (!DEMO) memory.putBytes("score", score, sizeof(score));
}

void showSchedule() {
  Serial.println("What the lamp has learned (score per half hour):");
  for (int s = 0; s < SLOTS; s++) {
    if (score[s] < 0.05) continue;
    printTime(s);
    Serial.print("  ");
    Serial.print(score[s], 2);
    Serial.println(score[s] > ON_ABOVE ? "  -> switches on by itself" : "");
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LAMP_PIN, OUTPUT);
  pinMode(AUTO_LED, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  memory.begin("lamp", false);
  if (!DEMO && memory.getBytes("score", score, sizeof(score)) == sizeof(score)) {
    Serial.println("Loaded what the lamp learned before");
  } else {
    for (int s = 0; s < SLOTS; s++) score[s] = 0;
  }
  if (!DEMO) {
    WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
    Serial.print("Connecting to Wi-Fi");
    while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print('.'); }
    configTzTime(TIME_ZONE, "pool.ntp.org");
    Serial.println(" connected, getting the time");
  }
  Serial.println("Press the button to switch the lamp. Type s for the schedule.");
}

void loop() {
  unsigned long now = millis();

  // 1. a new half hour? learn from the last one, then decide
  int s = currentSlot();
  if (s >= 0 && s != slot) {
    if (slot >= 0) learn(slot);
    slot = s;
    Serial.print("--- ");
    printTime(slot);
    Serial.println(" ---");
    onMsInSlot = 0;
    refused = false;
    if (lampOn) lampOnSince = now;
    if (!lampOn && score[slot] > ON_ABOVE) {
      setLamp(true, true);
      printTime(slot);
      Serial.println("  switching on by itself");
    } else if (autoOn && score[slot] < OFF_BELOW) {
      setLamp(false, false);
      printTime(slot);
      Serial.println("  switching off by itself");
    }
  }

  // 2. the button: short press switches, 3 s forgets everything
  static unsigned long downSince = 0;
  static bool wasDown = false;
  bool down = digitalRead(BUTTON_PIN) == LOW;
  if (down && !wasDown) downSince = now;
  if (!down && wasDown && now - downSince > 40) {
    if (now - downSince > 3000) {
      for (int i = 0; i < SLOTS; i++) score[i] = 0;
      memory.clear();
      Serial.println("Forgot everything");
    } else {
      if (autoOn) refused = true;          // it guessed, and you said no
      setLamp(!lampOn, false);
      Serial.println(lampOn ? "You switched the lamp on" : "You switched the lamp off");
    }
  }
  wasDown = down;

  if (Serial.available() && Serial.read() == 's') showSchedule();
  delay(10);
}

Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).

Try it

  1. Upload the sketch with DEMO set to true, and open the Serial Monitor at 115200 baud. Every 3 seconds a new "half hour" of the demo day starts, shown as --- 18:00 ---. Just above it, a line shows the score the lamp has just learned for the half hour that ended.
  2. Choose an evening in the demo day, for example from 18:00 to 20:00. When --- 18:00 --- appears, press the button straight away: the white LED switches on. When --- 20:00 --- appears, press again to switch it off. Be quick: a demo half hour lasts only 3 seconds, and the lamp must be on for at least half of it to count.
  3. Do the same on the next demo day, and the one after. Watch the scores for 18:00 to 19:30 grow: 0.30, then 0.51, then 0.66.
  4. On the fourth day, don't touch anything. At 18:00 the lamp switches on by itself, and the blue LED shows that it was the lamp's own idea. At 20:00 it switches itself off.
  5. Now change your habit: when it switches on by itself, switch it off straight away, every time it does it that evening. By the next demo day the scores have dropped below 0.6 and it stops guessing.
  6. Type s and press Enter to see the whole schedule the lamp has learned.

What you should see

Serial Monitor at 115200 baud over four demo days: the 18:00 score grows 0.30, 0.51, 0.66, then on day 4 the lamp and the blue LED switch on by themselves.

How it works

A ring of 48 half-hour slots with 18:00 to 19:30 learned, and a bar chart of the score after each day using score += 0.3 × (wanted − score) with thresholds 0.6 and 0.4.

One score for every half hour. The day is split into 48 half hours, called slots. Each slot has a score from 0 to 1: how sure the lamp is that you want light at that time. At the end of each slot, the lamp checks what really happened. If the light was on for at least half of the slot, the answer is "light wanted" (1), otherwise "not wanted" (0).

Learning a little at a time. The score moves towards the answer, but only part of the way:

  • score = score + RATE × (answer − score)
  • With RATE = 0.3, one evening with the light on moves a score of 0 up to 0.3. The next evening: 0.3 + 0.3 × (1 − 0.3) = 0.51. Then 0.66, which is above ON_ABOVE.
  • Old days slowly count less and less. This is called an exponential moving average.

The learning rate is a trade-off you will meet everywhere in machine learning. A high rate learns new habits fast, but one unusual evening can confuse it. A low rate is calm and steady, but takes weeks to notice that you changed your routine. Try 0.1 and 0.6 and compare.

Learning from mistakes. When the lamp switches on by itself and you switch it off, that's a clear "no". The sketch marks the slot as refused, so the whole half hour counts as "no" and the score moves towards 0, even if the light was on for part of it. Without this negative feedback, a smart device would keep repeating the same mistake.

Two thresholds. The lamp only switches on by itself above 0.6 and only switches itself off below 0.4. Like in a thermostat, the gap stops it switching back and forth when a score sits right on the edge.

Time and memory. Outside demo mode the ESP32 gets the time from the internet with NTP. TIME_ZONE is a rule that tells it your time zone and when summer time starts. The example is for Central Europe; search for "POSIX time zone" plus your city to find yours, for example EET-2EEST,M3.5.0/3,M10.5.0/4 for Estonia. Each time a slot ends, the scores are saved in the ESP32's flash memory with Preferences, so a power cut doesn't make it forget. Everything it learns stays on the ESP32: no cloud, no app, no data about your life leaves the room.

Try this next

  • Keep two schedules, one for weekdays and one for the weekend: use t.tm_wday to pick which one to learn.
  • Add the DHT11 and learn when you want a fan instead of a light.
  • Let the lamp fade on slowly when it switches itself on in the morning, like a sunrise alarm.
  • Compare with the artificial neuron lesson: there the AI learns from examples you give it on purpose. Here it learns just by watching what you do.

Troubleshooting

  • Nothing happens when I press the button: the button must sit across the middle gap of the breadboard, with one leg on GPIO 27 and the diagonally opposite leg on GND.
  • The white LED is quite dim: that's normal. A white LED needs almost 3 V, and the ESP32 only gives 3.3 V, so only a small current flows through the 220 Ω resistor.
  • The score never changes: the lamp must be on for at least half of a slot to count as "wanted". In demo mode that's 1.5 seconds.
  • It learned the wrong times: hold the button for 3 seconds to make it forget everything, and start again.
  • 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.
  • The times are an hour off: check TIME_ZONE for your country, including the summer-time rule.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit