Parent info
Parts you need
Affiliate links — we may earn a small commission
Know who’s home before you even leave your room.
Imagine this: your bedroom. Your phone. A web page you built. It shows: Dad — HOME (arrived 22 minutes ago). Sister — AWAY (left 1 hour ago). Your phone — HOME (obviously). Command center energy.
This runs on a single ESP32 plugged into a USB charger near the front door. No apps, no cloud service, no subscription. Just one $12 board and a web dashboard you serve yourself.
In 1.5 hours. For about $12.
What you’ll need
| Part | What it does | Price |
|---|---|---|
| ESP32-S3-DevKitC-1 | Scans for Bluetooth + serves the web dashboard | ~$12 |
| USB charger or power bank | Powers it 24/7 — any 5V USB will work | $0 |
| Breadboard + jumper wires | Just for the USB connection — no sensors needed | $0 |
Total: ~$12 | Time: ~1.5 hours | Difficulty: ●●●○○
Note on iPhones: Modern iPhones randomize their Bluetooth MAC address every few minutes for privacy. This means iPhone tracking is unreliable — the address changes and the tracker loses it. Android devices (and older iPhones) work consistently. For iPhones, you’d need to track a paired Bluetooth accessory instead.
How it works (60 seconds)
Think of it like a roll call that runs automatically every 30 seconds.
Every Bluetooth device broadcasts its MAC address — a unique hardware identifier — when Bluetooth is enabled. The ESP32 continuously scans for these broadcasts and checks them against your lookup table. If it sees “AA:BB:CC:DD:EE:01” in the scan and that’s in your table labeled “Dad’s Phone”, it marks Dad as HOME and records the time. If it hasn’t seen that address for 2 minutes, it marks him as AWAY.
You open the ESP32’s IP address in your phone browser and see who’s home in real time.
Step 0: Find the MAC addresses of phones you want to track
Time: ~5 minutes
Before wiring or coding — collect MAC addresses.
Android: Settings → About Phone → Status → Bluetooth Address
iPhone: Settings → General → About → Bluetooth (scroll down)
Write down the address for each phone. It looks like AA:BB:CC:DD:EE:FF.
Check: You have at least one MAC address written down. If you can’t find it in Settings, the next step’s code will also print every detected device MAC to Serial Monitor — you can grab it that way.
Step 1: No wiring needed
This is the easiest setup of all 8 spy projects:
- Plug the ESP32 into a USB charger
- No sensors, no breadboard required (unless you want one for stability)
- Position it near the front door for maximum range
The ESP32 has built-in Bluetooth Low Energy (BLE) hardware. It scans everything built-in.
Check: Just confirm the board powers on — the built-in LED should blink when it boots.
Step 2: Install libraries
In Arduino IDE: Sketch → Include Library → Manage Libraries
- Search “ESPAsyncWebServer” → Install
- Search “AsyncTCP” → Install (required by ESPAsyncWebServer)
The BLE libraries (BLEDevice.h, BLEScan.h) are already built into the ESP32 board package.
Step 3: Flash the code
Time: ~10 minutes
The big picture first. This program turns the ESP32 into a presence detector:
- The ESP32 is the brain — it listens to Bluetooth radio signals in the air.
- Every phone with Bluetooth on constantly broadcasts a tiny radio whisper: “I’m here!” — about 10 times per second. This whisper contains the phone’s Bluetooth MAC address, a unique ID, like a license plate.
- The code keeps a list of known MAC addresses (your family’s phones). Every 30 seconds, it checks: did I hear any of them? If yes → mark as HOME. If too long since last heard → mark as AWAY.
- A web page hosted on the ESP32 shows the status — open it in any phone browser.
Fill in your WiFi credentials and the MAC addresses you collected:
// ========== CHOOSE YOUR BOARD ==========
// Uncomment the line for YOUR board:
#define BOARD_S3 // ESP32-S3-DevKitC-1
//#define BOARD_C6 // ESP32-C6-DevKitC-1
// ========================================
// No board-specific pins — BLE works the same on both boards.
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
const char* WIFI_SSID = "YourWiFiName";
const char* WIFI_PASSWORD = "YourWiFiPassword";
const int SCAN_INTERVAL_MS = 30000;
const int ABSENT_TIMEOUT_S = 120;
struct KnownDevice {
const char* mac;
const char* name;
bool present;
unsigned long lastSeen;
};
KnownDevice devices[] = {
{"AA:BB:CC:DD:EE:01", "My Phone", false, 0},
{"AA:BB:CC:DD:EE:02", "Sister Samsung", false, 0},
{"AA:BB:CC:DD:EE:03", "Dad Pixel", false, 0},
};
const int DEVICE_COUNT = sizeof(devices) / sizeof(devices[0]);
BLEScan* pBLEScan;
AsyncWebServer server(80);
unsigned long lastScanTime = 0;
class AdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
String foundMAC = String(advertisedDevice.getAddress().toString().c_str());
foundMAC.toUpperCase();
for (int i = 0; i < DEVICE_COUNT; i++) {
String knownMAC = String(devices[i].mac);
knownMAC.toUpperCase();
if (foundMAC == knownMAC) {
if (!devices[i].present) {
Serial.println(String(devices[i].name) + " arrived home!");
}
devices[i].present = true;
devices[i].lastSeen = millis();
}
}
}
};
String buildDashboard() {
unsigned long now = millis();
String html = "<!DOCTYPE html><html><head>";
html += "<title>Target Acquired</title>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<meta http-equiv='refresh' content='30'>";
html += "<style>body{font-family:monospace;background:#111;color:#0f0;padding:20px;}";
html += "table{width:100%;border-collapse:collapse;}";
html += "td,th{padding:10px;border:1px solid #0f0;text-align:left;}";
html += ".present{color:#0f0;}.absent{color:#f00;}</style></head><body>";
html += "<h1>Operation Target Acquired</h1>";
html += "<table><tr><th>Device</th><th>Status</th><th>Last Seen</th></tr>";
for (int i = 0; i < DEVICE_COUNT; i++) {
if (devices[i].present && (now - devices[i].lastSeen) > (ABSENT_TIMEOUT_S * 1000UL)) {
devices[i].present = false;
Serial.println(String(devices[i].name) + " left.");
}
html += "<tr><td>" + String(devices[i].name) + "</td>";
html += "<td class='" + String(devices[i].present ? "present" : "absent") + "'>";
html += devices[i].present ? "HOME" : "AWAY";
html += "</td><td>";
if (devices[i].lastSeen > 0) {
unsigned long secAgo = (now - devices[i].lastSeen) / 1000;
html += String(secAgo) + "s ago";
} else {
html += "Never";
}
html += "</td></tr>";
}
html += "</table><p>Next scan in " +
String((SCAN_INTERVAL_MS - (now - lastScanTime)) / 1000) +
"s</p></body></html>";
return html;
}
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
}
Serial.println("\nDashboard: http://" + WiFi.localIP().toString());
BLEDevice::init("");
pBLEScan = BLEDevice::getScan();
pBLEScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallbacks());
pBLEScan->setActiveScan(true);
pBLEScan->setInterval(100);
pBLEScan->setWindow(99);
server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(200, "text/html", buildDashboard());
});
server.begin();
Serial.println("Operation Target Acquired: ARMED");
}
void loop() {
unsigned long now = millis();
if (now - lastScanTime > SCAN_INTERVAL_MS) {
lastScanTime = now;
Serial.println("Scanning for devices...");
BLEScanResults* results = pBLEScan->start(5, false);
Serial.println("Scan complete. Found " + String(results->getCount()) + " devices total.");
pBLEScan->clearResults();
}
delay(100);
}
Line-by-line: what every line does and why
Lines 1–5: Borrowing ready-made tools
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include means “grab this instruction book.” Someone already wrote how to operate the Bluetooth hardware and WiFi — we borrow their work.
- BLEDevice starts and controls the Bluetooth radio.
- BLEScan is the instruction book for listening to Bluetooth broadcasts.
- BLEAdvertisedDevice represents one device the scan finds — like one row in a list of overheard signals.
- WiFi connects the ESP32 to your home internet.
- ESPAsyncWebServer lets the ESP32 host a web page — like a tiny website running on your desk.
Lines 7–11: The settings you fill in
const char* WIFI_SSID = "YourWiFiName";
const char* WIFI_PASSWORD = "YourWiFiPassword";
const int SCAN_INTERVAL_MS = 30000;
const int ABSENT_TIMEOUT_S = 120;
const means “this never changes while the program runs — it’s locked in.” Think of it like writing something in permanent marker instead of pencil.
char* means “a word made of letters” (called a string). WIFI_SSID stores your WiFi network name. int means “a whole number.”
SCAN_INTERVAL_MS = 30000— the tracker scans every 30,000 milliseconds, which is 30 seconds. (1 second = 1,000 milliseconds.)ABSENT_TIMEOUT_S = 120— if a phone wasn’t seen for 120 seconds (2 minutes), mark it as AWAY.
Lines 13–19: A struct — a row in a table
struct KnownDevice {
const char* mac;
const char* name;
bool present;
unsigned long lastSeen;
};
A struct is like a row in a spreadsheet. Each row has several columns. Here, each known phone has 4 columns:
mac— the phone’s unique Bluetooth address (like a license plate).name— the friendly label you give it (“Dad Pixel”).bool present—boolis a yes/no box.true= HOME,false= AWAY.unsigned long lastSeen— a timestamp: “when did I last hear this phone?”unsigned longis a very large whole number — it needs to be big becausemillis()counts up to billions of milliseconds.
Lines 21–27: The list of tracked phones
KnownDevice devices[] = {
{"AA:BB:CC:DD:EE:01", "My Phone", false, 0},
{"AA:BB:CC:DD:EE:02", "Sister Samsung", false, 0},
{"AA:BB:CC:DD:EE:03", "Dad Pixel", false, 0},
};
const int DEVICE_COUNT = sizeof(devices) / sizeof(devices[0]);
devices[] is a shelf with compartments — one compartment per phone. The [] means “it’s a list.” Each item fills in the four columns from the struct.
sizeof(devices) / sizeof(devices[0]) is a trick to automatically count how many phones are in the list. If you add a fourth phone, DEVICE_COUNT updates by itself. You never have to write “4” manually.
Lines 29–31: Creating the tools
BLEScan* pBLEScan;
AsyncWebServer server(80);
unsigned long lastScanTime = 0;
BLEScan* pBLEScan— creates the Bluetooth scanning tool. The*means it’s a pointer (an address to where the tool lives in memory) — don’t worry about that detail yet.AsyncWebServer server(80)— creates the web server.80is the port number — like an apartment number in a building. Web browsers always knock on port 80 by default.lastScanTime = 0— a sticky note starting at 0. It records “when did we last scan?” so we know when 30 seconds have passed.
Lines 33–48: The callback — runs automatically for each found device
class AdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
String foundMAC = String(advertisedDevice.getAddress().toString().c_str());
foundMAC.toUpperCase();
for (int i = 0; i < DEVICE_COUNT; i++) {
String knownMAC = String(devices[i].mac);
knownMAC.toUpperCase();
if (foundMAC == knownMAC) {
if (!devices[i].present) {
Serial.println(String(devices[i].name) + " arrived home!");
}
devices[i].present = true;
devices[i].lastSeen = millis();
}
}
}
};
A class is a recipe for an object. This class contains one function: onResult(). The BLE scan library calls onResult() automatically for every device it hears during a scan — like a robot that calls your name every time it spots a car on the street.
foundMAC.toUpperCase() — MAC addresses can be written as aa:bb:cc or AA:BB:CC. We convert both sides to uppercase so AA:BB:CC == AA:BB:CC (and not aa:BB:CC != AA:BB:CC).
for (int i = 0; i < DEVICE_COUNT; i++) — a loop that goes through every phone in the list. i starts at 0, increases by 1 each round (i++), and stops when it reaches DEVICE_COUNT.
if (foundMAC == knownMAC) — == means “is it equal?” (a question, not a command). If the heard address matches one we know, mark that phone as HOME and record the time.
!devices[i].present — the ! means NOT. So “if the phone was NOT already marked present” — meaning it just arrived.
Lines 50–89: Building the dashboard web page
String buildDashboard() { ... }
This is a function — a recipe with a name. When called, it builds the HTML text for the web page, piece by piece, using html += (which means “add this to the end of the string”).
Inside, the for loop goes through every tracked phone and adds a table row showing its name, HOME/AWAY status, and how many seconds ago it was last seen.
(now - devices[i].lastSeen) > (ABSENT_TIMEOUT_S * 1000UL) — this checks “has it been more than 2 minutes since we heard this phone?” If yes, flip it to AWAY. 1000UL converts seconds to milliseconds (UL means the number is an unsigned long).
Lines 91–116: setup() runs once at power-on
void setup() { ... }
setup() is the morning routine — runs once, prepares everything:
- Start the USB “phone line” for Serial Monitor.
- Connect to WiFi — the
whileloop keeps waiting (printing dots) until connected. - Print the dashboard web address.
- Start the Bluetooth hardware with
BLEDevice::init(""). - Get a reference to the scanner and attach the callback class.
setActiveScan(true)— active scanning asks devices for their names (more detail, tiny bit more power).- Register the web page: “when someone opens
/, send them the dashboard.” - Start the web server.
Lines 118–128: loop() repeats forever
void loop() {
unsigned long now = millis();
if (now - lastScanTime > SCAN_INTERVAL_MS) {
lastScanTime = now;
Serial.println("Scanning for devices...");
BLEScanResults* results = pBLEScan->start(5, false);
Serial.println("Scan complete. Found " + String(results->getCount()) + " devices total.");
pBLEScan->clearResults();
}
delay(100);
}
loop() beats like a heartbeat — thousands of times per second.
millis() is a stopwatch that started the moment the board powered on. now - lastScanTime is “time since last scan.” When that number exceeds 30,000 (30 seconds), a new scan starts.
pBLEScan->start(5, false) — scan for 5 seconds. During those 5 seconds, onResult() fires automatically for every device heard. false means “don’t block forever.”
pBLEScan->clearResults() — throw away the scan results from memory. Like wiping a whiteboard clean so the next scan starts fresh.
delay(100) — wait 100 milliseconds before looping again. The web server handles page requests inside this waiting time.
The whole thing in one sentence
Every 30 seconds the tracker listens for Bluetooth signals (loop), and when it hears a known phone it marks that person as HOME and records the time (onResult). When a phone goes quiet for 2 minutes, the dashboard shows AWAY.
First thing to try: change SCAN_INTERVAL_MS from 30000 to 5000 and upload. The tracker now scans every 5 seconds. Walk away with your phone and watch how quickly it detects you’re gone.
Check: Open Serial Monitor at 115200 baud. You should see “Dashboard: http://192.168.x.x” then “Operation Target Acquired: ARMED”. After 30 seconds, you’ll see “Scanning for devices…” followed by a count.
Step 4: Open your command center
Time: ~2 minutes
On your phone:
- Open browser
- Type the IP address from Serial Monitor
- The presence dashboard loads
You’ll see each registered device with its status (HOME / AWAY) and when it was last spotted.
Walk around with your phone and watch the dashboard update. When the ESP32 spots your phone’s MAC in the scan, it marks you as HOME. When you leave range and 2 minutes pass, it marks you as AWAY.
Pro placement: Put the ESP32 near the front door or hallway for the most reliable detection. Thick concrete walls reduce BLE range significantly.
What just happened (what you learned)
-
BLE advertisement is a broadcast signal every Bluetooth device sends periodically — like a phone calling out “I’m here!” roughly every 100ms. Passive scanning just listens for these broadcasts without responding.
-
RSSI (Received Signal Strength Indicator) measures how strong the signal is in dBm. A value like -45 means “very close” and -90 means “far away or through many walls.” You could use this to estimate distance.
-
MAC address randomization is a privacy feature on modern iPhones (and some Androids) that rotates the Bluetooth MAC every few minutes. This is why iPhone tracking is unreliable — your code matches a fixed MAC, but Apple keeps changing it.
-
Callback class is a design pattern where you give the library an object and it calls your methods on it.
AdvertisedDeviceCallbacks::onResult()is never called by you — the BLE library calls it internally every time a device is found during a scan. -
sizeof(array)/sizeof(element)is a C trick to count array elements at compile time. If you add a fourth device,DEVICE_COUNTupdates automatically.
Level Up
Arrival notifications: When a known device transitions from AWAY to HOME, send a Telegram notification: “Dad just arrived home.” Add UniversalTelegramBot and call bot.sendMessage() when !devices[i].present flips to true.
Unknown visitor detection: If any unknown MAC appears in 3 consecutive scans, add it to a separate unknownVisitors[] array with a first-seen timestamp. Display it on the dashboard under “Unregistered Devices.” This is how real building security systems work.
Better time display: Change “2340s ago” to “39 min ago” by dividing by 60. Change “7200s ago” to “2 hrs ago” by dividing by 3600. Human-readable time makes the dashboard much more useful.
★★ You completed: Bluetooth Device Tracker!
Troubleshooting
| Problem | Fix |
|---|---|
| Device never shows as HOME | Make sure Bluetooth is enabled on the target phone. Check that the MAC address in your code matches exactly (uppercase, with colons). |
| Device shows HOME immediately on boot | Stale initialization — add a devices[i].lastSeen = 0 reset at startup to ensure all devices start as AWAY. |
| Dashboard always shows “Never” | Scan is running but no known devices found. Walk with your phone near the ESP32. Check MAC address format in the code. |
| Dashboard won’t load in browser | Wrong IP address. Check Serial Monitor for the current IP. Try refreshing — first load can take 3-5 seconds. |
| iPhone tracking unreliable | Expected behavior. iPhones randomize MAC addresses. Switch to tracking Android devices or use a paired accessory. |
| “Scan complete. Found 0 devices” | BLE not initialized correctly. Confirm you’re using an ESP32 (not ESP8266). Try reducing scan interval to 10 seconds for testing. |