Learning Goals 5 min
Cluster G's capstone is the project that always sells the IoT idea: press a button, anywhere in the world someone's phone buzzes. We've had all the pieces — WiFi, NTP, webhooks, IFTTT — and today we mount them into a real doorbell-shaped device. By the end of this lesson you will:
- Build a finished "DM doorbell": button + ESP + buzzer + LED, mounted in a small enclosure, powered by USB or battery.
- Add three layers of feedback: visible LED "sending" flash, audible buzzer chime, and remote phone notification — so both the visitor and the home owner know the doorbell worked.
- Add resilience: WiFi reconnection, NTP re-sync, webhook retry on failure, queued events that survive a brief outage.
Warm-Up 10 min
Pull together the kit for today:
- ESP (NodeMCU or ESP32).
- Push button — any tactile button is fine; a real doorbell-button is bonus.
- Piezo buzzer for the audible chime.
- LED + 220 Ω resistor for the "sending" indicator.
- USB cable or a 5 V USB power bank for untethered power.
- Cardboard / plastic box for the enclosure.
- IFTTT applet from yesterday + a Discord webhook URL.
Product specification first
Before coding, write the spec in your notebook:
- Visitor experience: presses button → hears chime within 100 ms → sees LED flash → walks away.
- Owner experience: within 10 s, phone vibrates with "Doorbell at 19:30" notification. Also a Discord log entry.
- Outage behaviour: WiFi drops? Phone still gets the notification within 30 s once the connection comes back. Buzzer + LED never affected.
This spec drives the architecture in §3.
New Concept · Local-first, network-second 20 min
Why local feedback first
The buzzer + LED must fire immediately on button press — synchronous, in the interrupt or main loop. The webhook then happens after local feedback completes. If the network is slow, the visitor still gets a real chime.
Bad order (don't): button → webhook (takes ~3 s) → buzzer. The visitor stands awkwardly waiting, presses again, you get spam alerts.
Good order: button → buzzer + LED → webhook in background. We'll keep the webhook synchronous in the loop for simplicity, but immediately after the local feedback so the perceived delay is zero.
Retry queue for offline events
If WiFi is down when the bell rings, we don't want to lose the event. Store the timestamp in a queue; flush when the connection comes back.
#define QUEUE_SIZE 5
time_t queue[QUEUE_SIZE];
int qHead = 0, qTail = 0, qCount = 0;
bool enqueue(time_t t) {
if (qCount == QUEUE_SIZE) return false; // queue full, drop event
queue[qTail] = t;
qTail = (qTail + 1) % QUEUE_SIZE;
qCount++;
return true;
}
bool peek(time_t& t) {
if (qCount == 0) return false;
t = queue[qHead];
return true;
}
void dropHead() {
qHead = (qHead + 1) % QUEUE_SIZE;
qCount--;
}A small ring buffer. Holds up to 5 unsent doorbell events. On every loop iteration, if WiFi is connected, try to send the head of the queue; on success, drop the head.
The three-layer feedback timing
| Layer | Trigger | Latency target |
|---|---|---|
| 1. Buzzer + LED | Button press | < 50 ms |
| 2. Discord embed | After local feedback | ~200–500 ms |
| 3. Phone push notification (IFTTT) | After Discord | ~5–15 s |
The visitor only sees layer 1. The owner sees layer 3. Layer 2 is the developer's "always-on log" — handy for debugging weeks later when someone says "the doorbell never worked yesterday" and you can check the Discord channel.
Worked Example · The finished DM doorbell 25 min
Step 1 — wire it
| Component | ESP pin |
|---|---|
| Doorbell button | D7 (GPIO13), INPUT_PULLUP |
| Buzzer + | D6 (GPIO12) |
| Buzzer − | GND |
| LED + 220 Ω | D5 (GPIO14) |
Step 2 — the sketch
// L03-38 · Doorbell That DMs You
// Button -> buzzer + LED -> Discord embed -> IFTTT phone notification
// With offline event queue: events captured during WiFi outage are flushed on reconnect.
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecureBearSSL.h>
using SecureClient = BearSSL::WiFiClientSecure;
#elif defined(ESP32)
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
using SecureClient = WiFiClientSecure;
#endif
#include <ArduinoJson.h>
#include <time.h>
const char* SSID = "YourNetwork";
const char* PASSWORD = "YourPassword";
const char* DISCORD_URL = "https://discord.com/api/webhooks/REPLACE_ME";
const char* IFTTT_URL = "https://maker.ifttt.com/trigger/doorbell/with/key/REPLACE_ME";
const int BTN_PIN = 13;
const int BUZZER = 12;
const int LED = 14;
const unsigned long DEBOUNCE_MS = 50;
const unsigned long COOLDOWN_MS = 5000;
bool lastBtn = HIGH;
unsigned long lastEdge = 0;
unsigned long lastSentAt = 0;
#define QUEUE_SIZE 5
time_t qBuf[QUEUE_SIZE];
int qHead = 0, qTail = 0, qCount = 0;
bool enqueue(time_t t) {
if (qCount == QUEUE_SIZE) { Serial.println("# queue full, dropping"); return false; }
qBuf[qTail] = t;
qTail = (qTail + 1) % QUEUE_SIZE;
qCount++;
return true;
}
void playChime() {
// 2-note chime, ~150 ms total
tone(BUZZER, 880, 70);
delay(80);
tone(BUZZER, 1320, 70);
}
void flashLED() {
digitalWrite(LED, HIGH);
// We let loop turn it off after the webhook
}
bool postJSON(const char* url, const String& body) {
if (WiFi.status() != WL_CONNECTED) return false;
SecureClient client;
client.setInsecure();
HTTPClient http;
http.begin(client, url);
http.addHeader("Content-Type", "application/json");
int status = http.POST(body);
Serial.print("# POST -> "); Serial.println(status);
http.end();
return status >= 200 && status < 300;
}
bool sendEvent(time_t t) {
struct tm tmEv;
localtime_r(&t, &tmEv);
char timeBuf[20];
strftime(timeBuf, sizeof(timeBuf), "%H:%M:%S", &tmEv);
// Discord embed
StaticJsonDocument<384> doc;
JsonObject embed = doc["embeds"].createNestedObject();
embed["title"] = "Doorbell pressed";
embed["description"] = "Someone's at the door.";
embed["color"] = 0x58A4FF;
JsonArray fields = embed.createNestedArray("fields");
JsonObject f1 = fields.createNestedObject();
f1["name"] = "Time"; f1["value"] = timeBuf; f1["inline"] = true;
String dBody; serializeJson(doc, dBody);
if (!postJSON(DISCORD_URL, dBody)) return false;
// IFTTT phone notification
String iBody = "{\"value1\":\"";
iBody += timeBuf;
iBody += "\"}";
postJSON(IFTTT_URL, iBody); // failure here doesn't block the queue drop
return true;
}
void flushQueue() {
if (qCount == 0 || WiFi.status() != WL_CONNECTED) return;
if (millis() - lastSentAt < 1000) return; // rate limit ourselves a little
time_t t = qBuf[qHead];
if (sendEvent(t)) {
qHead = (qHead + 1) % QUEUE_SIZE;
qCount--;
lastSentAt = millis();
Serial.print("# Sent. Queue depth = "); Serial.println(qCount);
digitalWrite(LED, LOW); // clear "pending" LED when queue empties
if (qCount == 0) digitalWrite(LED, LOW);
}
}
void onPress() {
if (millis() - lastSentAt < COOLDOWN_MS) return;
Serial.println("# Bell pressed");
flashLED(); // local "received" indicator
playChime(); // local audible chime
time_t now = time(nullptr);
if (now < 24 * 3600) now = millis() / 1000; // NTP not ready -> use uptime
enqueue(now);
}
void checkButton() {
bool b = digitalRead(BTN_PIN);
if (b == lastBtn) return;
if (millis() - lastEdge < DEBOUNCE_MS) return;
lastEdge = millis();
lastBtn = b;
if (b == LOW) onPress();
}
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
pinMode(BUZZER, OUTPUT);
pinMode(LED, OUTPUT);
digitalWrite(LED, LOW);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
delay(500); Serial.print(".");
}
Serial.println();
configTime(0, 0, "pool.ntp.org");
setenv("TZ", "MYT-8", 1);
tzset();
Serial.println("# Doorbell armed.");
}
void loop() {
// Maintain WiFi
if (WiFi.status() != WL_CONNECTED) {
static unsigned long lastTry = 0;
if (millis() - lastTry > 5000) {
lastTry = millis();
WiFi.disconnect();
WiFi.begin(SSID, PASSWORD);
}
}
checkButton();
flushQueue();
}Step 3 — bench test
- Press the button. You hear "ding-ding" immediately. LED lights up. Within ~2 s, Discord shows the embed; within ~10 s, the phone notification.
- Disconnect your WiFi (turn off the hotspot). Press the button. Local chime + LED fire as before; LED stays on (queue has one pending). The serial log shows queued status.
- Re-enable WiFi. Within ~5 s, the queued event flushes; LED goes off; phone gets the notification with the correct earlier timestamp.
Step 4 — build the enclosure
Cardboard box, button on top, ESP + buzzer + LED inside. Wires tidy. USB cable out the back. Label "Press for delivery" or whatever fits your context. Stick it on the front door / fridge / desk.
Step 5 — leave it running for 24 hours
Put it somewhere. Have family / housemates press it occasionally. Check the Discord channel — you have a free, durable log of all activity. Check IFTTT — phone notifications fire on schedule.
Step 6 — measure end-to-end latency in practice
Press the button. Watch the phone (have a stopwatch). Average over 5 trials. Typical: chime 0 ms, Discord embed 200 ms, IFTTT phone notification 6–12 s. Tell visitors "wait 5 seconds".
Try It Yourself 15 min
Goal: Change the chime to a melody — three notes of any tune (e.g. NBC's G-E-C chime).
Hint
void playChime() {
tone(BUZZER, 784, 200); delay(220); // G5
tone(BUZZER, 659, 200); delay(220); // E5
tone(BUZZER, 523, 400); delay(420); // C5
}The total chime is now ~860 ms — long enough to feel like a real doorbell, short enough not to annoy.
Goal: Add a PIR motion sensor on D8. Fire the webhook (without chime) if someone approaches but doesn't press. Different IFTTT event (doorbell_motion) → different phone notification ("Someone's at the door, no press").
Hint
PIR is digital; HIGH while motion detected. Debounce (motion has a hold time). Same queue, separate sendEvent variant that POSTs to the motion URL.
Goal: Add a photo URL to the Discord embed. (You don't have a camera, but you can pretend.) Discord embeds support an image field — if you have a static placeholder image online, include it.
Hint
embed["image"]["url"] = "https://placehold.co/600x400?text=Visitor";Discord renders the image inside the embed. For a real version, an ESP32-CAM (covered conceptually in L04) snaps a photo, uploads it to a server, and references the URL here.
Mini-Challenge · Ship the doorbell 10 min
- Mount the build in a small enclosure (cardboard box, takeaway container, 3D-printed case).
- Label the button clearly ("Press for [your name]").
- Cable management — no exposed wires.
- Take a photo of the finished device.
- Video a 30-second demo: press → chime → LED → Discord embed appears → phone buzzes. Show the timestamps so the latency is visible.
- Offline test: turn off WiFi mid-recording. Press button → local feedback fires; queue grows. Restore WiFi → queue flushes → phone notification arrives. The resilience demo.
Ship-ready test:
- Hand to a parent. Do they understand what to press?
- When they press, do you actually get the notification on your phone?
- If they press 10 times in a row, do you get 10 notifications or just one? (Cooldown should suppress; document the behaviour you chose.)
Cluster G is done. Cluster H starts tomorrow with the missing piece of every L3 build: how to write code that's actually reusable.
Recap 5 min
The DM doorbell glues everything from Cluster F + G: WiFi, NTP, dual webhooks. The new ideas were local-first feedback (chime / LED before webhook) and a small retry queue for offline resilience. The architecture is exactly how real connected doorbells (Ring, Nest) work, minus the camera and the cloud — and the cloud is just a server instead of Discord. With a webhook URL and 100 lines of code you can replicate a $200 commercial product's core function. Cluster H next: making your code reusable so the next project doesn't need this much re-typing.
- Local-first feedback
- Trigger local outputs (sound, LED) before any network call so the user's perceived latency is zero.
- Offline queue
- A small in-memory buffer of unsent events. On network restore, flush in order. Stops transient outages from losing events.
- Ring buffer
- A fixed-size circular queue. Head and tail pointers wrap around the array. Constant-time enqueue / dequeue; no dynamic allocation.
- Cooldown
- Minimum time between accepted events to prevent button-mashing spam.
- Three-layer feedback
- Three signals at different latency targets: instant (local hardware), fast (Discord log), eventual (phone push). Each audience sees the appropriate one.
- WiFi reconnection logic
- Code that detects WiFi drops and re-issues
WiFi.begin()at intervals. Required for any unattended device. - Resilience
- The property of continuing to work through partial failures. For us: button still chimes locally even when WiFi is down; events still arrive in order once it recovers.
Homework 5 min
- Finish the build. Document with photo + 30-second demo video.
- Run the offline-restore test. Note in your notebook how long the queue holds events and what happens when it's full.
- Save as
dm-doorbell.ino. - Read ahead to ARD-L03-39 (Arrays and Lookups). Tomorrow we start Cluster H — making your code reusable.
Bring back next class:
- Finished doorbell + video.
- Sketch saved.