This build follows directly from our CSI capture guide. There we turned raw Wi-Fi packets into one energy number per packet. Here we turn a stream of those numbers into a decision: is the room empty, or did something just move. If you have not captured CSI before, read that guide first, then come back.
01How the alarm works
The logic is deliberately simple, because simple is what stays reliable. The board joins your own Wi-Fi so there is a link to measure. For a short warm-up it watches an empty, still room and records how calm the signal is. That calm level becomes the baseline. After that, it keeps measuring how much the signal is churning, and when the churn rises well above the baseline it declares motion, holds the alarm for a few seconds so it does not chatter, and drives a buzzer.
Everything runs on the board. No cloud, no camera, no image of anyone. It is a presence and motion trigger for a space you own.
02Learn the empty room
CSI is never perfectly still, even in an empty room. There is always a little noise from the radio and the environment. The whole trick of a good detector is to measure that resting level first, then react only to changes that clearly exceed it. So the alarm begins with a warm-up window, about fifteen seconds by default, during which the room must be empty and nobody should walk past.
03From CSI to a motion score
A single energy value jumps around from packet to packet, so we do not compare raw energy. Instead we look at how variable the recent energy is. A still room gives a low, steady variance. A person moving stirs the signal, and the variance climbs. That variance is our motion score: one number that is low when nothing happens and high when it does.
The threshold is set from the baseline: a multiple of the quiet variance, so a louder room automatically needs a bigger movement to trip. That keeps one set of numbers working across different rooms without hand-tuning for each.
04Threshold, debounce and the buzzer
Three small details make the difference between a toy and something you would actually leave running:
- Threshold. Trigger only when the score is several times the baseline, not a hair above it. This is the single most important dial.
- Debounce. Motion is bursty, so once triggered we hold the alarm on for a few seconds. Without this the buzzer would stutter on and off as the score wobbles around the line.
- The action. Here it is a buzzer on a GPIO pin, but it is one line of code, so it is easy to swap for a message or a webhook later.
05The full sketch
Wire an active buzzer between GPIO 4 and ground, put your own network details in, and flash it. Then follow the steps below.
- Connect the buzzer. Active buzzer positive to GPIO 4, negative to GND. An active buzzer sounds whenever the pin is HIGH.
- Set your network. Put your own SSID and password in the two constants. Use only a network you own.
- Flash and leave the room. Open the serial monitor at 115200, start the board, and step out during the warm-up.
- Test it. After the baseline prints, walk back in. The score should spike and the buzzer should sound.
- Tune. If it is twitchy or deaf, adjust the trigger multiplier and window, as described in the next section.
// Camera-free Wi-Fi CSI motion alarm for ESP32 / ESP32-S3 / ESP32-C. // Learns an empty room, then sounds a buzzer when CSI variance spikes. #include <WiFi.h> #include "esp_wifi.h" const char* SSID = "YOUR_WIFI"; // your own network const char* PASS = "YOUR_PASSWORD"; const int BUZZER_PIN = 4; // active buzzer: HIGH = on const uint32_t WARMUP_MS = 15000; // learn the empty room for 15 s const int WIN = 32; // samples in the motion window const float TRIGGER_K = 6.0; // times the baseline before we alarm const uint32_t HOLD_MS = 4000; // keep alarm on this long (debounce) volatile double gEnergy = 0; // latest per-packet CSI energy volatile uint32_t gSeq = 0; // increments on every CSI record // Wi-Fi task: turn one packet into one energy value. Keep it short. static void onCsi(void* ctx, wifi_csi_info_t* info) { if (!info || !info->buf) return; int8_t* d = info->buf; int n = info->len / 2; double e = 0; for (int i = 0; i < n; i++) { int imag = d[i*2], real = d[i*2+1]; e += (double)(real*real + imag*imag); } gEnergy = e; gSeq++; } double win[WIN]; int widx = 0; bool wfull = false; // Variance of the recent energy values = our motion score. double motionScore() { int c = wfull ? WIN : widx; if (c < 2) return 0; double m = 0; for (int i=0;i<c;i++) m += win[i]; m /= c; double v = 0; for (int i=0;i<c;i++){ double dd=win[i]-m; v += dd*dd; } return v / c; } double baseline = 0, baseSum = 0; int baseCnt = 0; bool learned = false; uint32_t startMs = 0, alarmUntil = 0, lastSeq = 0; void setup() { Serial.begin(115200); delay(300); pinMode(BUZZER_PIN, OUTPUT); digitalWrite(BUZZER_PIN, LOW); WiFi.mode(WIFI_STA); WiFi.begin(SSID, PASS); while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print("."); } Serial.println("\nconnected"); wifi_csi_config_t cfg = { .lltf_en=true, .htltf_en=true, .stbc_htltf2_en=true, .ltf_merge_en=true, .channel_filter_en=false, .manu_scale=false, .shift=0 }; esp_wifi_set_csi_config(&cfg); esp_wifi_set_csi_rx_cb(&onCsi, NULL); esp_wifi_set_csi(true); startMs = millis(); Serial.println("Warming up: keep the room empty and still..."); } void loop() { // pull the newest energy sample into the window if (gSeq != lastSeq) { lastSeq = gSeq; win[widx] = gEnergy; widx = (widx+1) % WIN; if (widx==0) wfull=true; } double score = motionScore(); uint32_t now = millis(); if (!learned) { if (now - startMs < WARMUP_MS) { baseSum += score; baseCnt++; } else { baseline = baseCnt ? baseSum/baseCnt : score; learned = true; Serial.printf("baseline = %.0f (armed)\n", baseline); } } else { double thr = baseline * TRIGGER_K + 1.0; // +1 guards a near-zero baseline if (score > thr) alarmUntil = now + HOLD_MS; // (re)trigger and debounce bool on = now < alarmUntil; digitalWrite(BUZZER_PIN, on ? HIGH : LOW); Serial.printf("score=%.0f thr=%.0f %s\n", score, thr, on ? "ALARM" : "quiet"); } delay(40); // ~25 Hz decision rate }
06Tuning it for your room
Out of the box the alarm works, but every room is different. Three dials cover almost everything:
| Dial | If it fires too easily | If it misses people |
|---|---|---|
TRIGGER_K | Raise it (8, 10) | Lower it (4, 5) |
WIN | Increase for a calmer score | Decrease for a faster reaction |
WARMUP_MS | Lengthen for a steadier baseline | Make sure the room was truly empty |
The most common cause of false alarms is something in the room that moves on its own. A fan, an air-conditioning vent, a curtain in a draft or a pet will all stir the channel. Point the link across the area you care about rather than at a vent, raise TRIGGER_K so small motion is ignored, and if pets are the issue, accept that a single CSI link cannot reliably tell a cat from a person, so pair it with another sensor for pet-immune alarming. We compare CSI with dedicated presence hardware in mmWave radar vs PIR.
07Make it yours
The buzzer is only a placeholder for the moment the score crosses the threshold. From that exact point in the code you can do anything the board can do. Because it is already on your Wi-Fi, the natural upgrades are a push notification through your home automation system, a webhook to a service you run, or an MQTT message other devices listen for. You could also log the score to build a picture of when a room is normally used.
Keep the framing in mind: this is a sensor for your own space. It tells you a room you own is occupied, with no camera and no recording of anyone. That is the strength of Wi-Fi sensing, and the reason to build it this way. For the theory behind all of this, circle back to the complete CSI guide.
08FAQ
How long does the warm-up take?
The sketch learns the empty room for about fifteen seconds by default, and you can make it longer for a more stable baseline. During warm-up the room must be empty and still, because whatever it sees then becomes the quiet reference that every later reading is compared against.
What causes false alarms, and how do I stop them?
Anything that moves the radio channel can trip it: a fan, a pet, a curtain in a draft, or a phone waking up nearby. Raise the trigger multiplier so small movements are ignored, average over a longer window, and place the board and router so the air path across the room, not a vent, is what the link covers.
Can the alarm send a phone alert instead of a buzzer?
Yes. The buzzer line is just one action taken when the score crosses the threshold. Because the board is already on your Wi-Fi, you can replace or add to it: send a message to a home automation system, call a webhook, or publish over MQTT, all from the same trigger point in the code.
Does the ESP32 need internet for this alarm?
No. It only needs to join your own local access point so there is a Wi-Fi link to measure. The detection runs entirely on the board, and the buzzer works with no internet at all. Internet is only needed if you choose to add remote alerts that leave your network.