In this project, I built a compact, real-time motion detection system using the ultra-small Beetle ESP32-C3 board and a PIR sensor, designed for low-power IoT deployments and instant alerting. Whether you're monitoring a workspace, detecting intrusions, or triggering automation, this setup offers a reliable and modular solution with webhook-based notifications.
🔧 Hardware Used- Beetle ESP32-C3 – A tiny RISC-V microcontroller with Wi-Fi and BLE, perfect for compact IoT builds.
- PIR Motion Sensor – Detects infrared changes caused by movement.
- Custom 3D-Printed Case – Designed for this build and printed via JustWay.
- USB-C Cable – For power and programming.
The PIR sensor continuously monitors for motion. When movement is detected, the Beetle ESP32-C3 sends a webhook trigger to a cloud service, messaging app, or automation platform. This allows for real-time alerts without needing a local display or interface.
You can integrate this with:
- IFTTT or Zapier for email/SMS alerts
- Telegram bots for instant messaging
- Home Assistant or Node-RED for smart home automation
The firmware is written in Arduino using Webserver It connects to Wi-Fi, monitors the PIR sensor, and sends a HTTP Server trigger when motion is detected.
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiUdp.h>
#include <NTPClient.h>
// ---------- Configuration ----------
const char* ssid = "Sellamuthu";
const char* password = "36180402";
IPAddress local_IP(192, 168, 1, 11);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress primaryDNS(8, 8, 8, 8);
IPAddress secondaryDNS(8, 8, 4, 4);
// Pins
const int PIR_SENSOR_OUTPUT_PIN = 0;
const int LED_PIN = 10;
// Motion tracking
bool motionState = false;
int motionCount = 0;
#define HISTORY_SIZE 5
String motionHistory[HISTORY_SIZE];
int historyIndex = 0;
String lastMotionTime = "None";
// Server & time
WebServer server(80);
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", 19800, 1000); // IST offset 19800s, update interval = 1s
unsigned long bootMillis = 0;
// ---------- Helper functions ----------
String buildHistoryHTML() {
String html = "";
// show newest first
for (int i = 0; i < HISTORY_SIZE; i++) {
int idx = (historyIndex - 1 - i + HISTORY_SIZE) % HISTORY_SIZE;
if (motionHistory[idx] != "") {
html += "<li>" + motionHistory[idx] + "</li>";
}
}
return html;
}
String getSignalQualityPercent() {
int rssi = WiFi.RSSI();
int quality = map(rssi, -100, -50, 0, 100);
quality = constrain(quality, 0, 100);
return String(quality);
}
// ---------- HTTP handlers ----------
void handleRoot() {
String historyHTML = buildHistoryHTML();
String signal = getSignalQualityPercent();
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="20">
<title>ESP32-C3 Motion Dashboard</title>
<style>
:root { --bg:#0f1113; --card:#161719; --muted:#9aa0a6; --accent:#00ffd5; --good:#00cc66; --bad:#ff5757; color-scheme: dark; }
body { margin:0; font-family:Segoe UI, Roboto, Arial; background:var(--bg); color:#e9eef0; display:flex; justify-content:center; padding:18px; }
.container { width:360px; }
h1 { margin:6px 0 14px 0; color:var(--accent); font-size:20px; text-align:center; }
.card { background:var(--card); padding:14px; border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,0.6); margin-bottom:12px; }
.label { color:var(--muted); font-size:12px; margin-bottom:6px; }
.status { font-size:18px; margin-bottom:8px; }
.small { font-size:13px; color:var(--muted); }
.row { display:flex; justify-content:space-between; align-items:center; gap:10px; }
ul { padding-left:18px; margin:6px 0; }
button, select { padding:8px 10px; border-radius:6px; border:0; background:#222; color:#e9eef0; cursor:pointer; }
.signalbar { background:#222; border-radius:6px; height:14px; width:100%; overflow:hidden; }
.signalfill { height:100%; background:linear-gradient(90deg,#00ff7a,#00a3ff); width:0%; }
.green { color:var(--good); }
.red { color:var(--bad); }
body.light { --bg:#f3f6f8; --card:#ffffff; --muted:#6b747b; --accent:#00796b; color-scheme: light; color:#0b1114; }
body.light .signalfill { background:linear-gradient(90deg,#00796b,#005b9a); }
</style>
</head>
<body>
<div class="container">
<h1>ESP32-C3 Motion Dashboard</h1>
<div class="card">
<div class="label">Current Time</div>
<div id="clock" class="status">--:--:--</div>
<div class="small">Device IP: <span id="ip">%IP%</span> · SSID: <span id="ssid">%SSID%</span></div>
</div>
<div class="card">
<div class="label">Motion Status</div>
<div id="motionStatus" class="status">Loading...</div>
<div class="label">Last Motion Time</div>
<div id="lastMotion" class="status">%LAST_MOTION%</div>
<div class="label">Motion Count</div>
<div id="motionCount" class="status">%MOTION_COUNT%</div>
<div class="label">Recent Motion Events</div>
<ul id="history">%MOTION_HISTORY%</ul>
<div style="margin-top:8px;" class="row">
<button onclick="resetData()">🔄 Reset Data</button>
<button onclick="togglePolling()" id="pollBtn">Pause Poll</button>
</div>
</div>
<div class="card">
<div class="label">WiFi Signal</div>
<div style="display:flex;gap:8px;align-items:center;">
<div style="flex:1;">
<div class="signalbar"><div id="signalfill" class="signalfill" style="width:%SIGNAL%%"></div></div>
</div>
<div style="width:44px;text-align:right;"><strong id="signalText">%SIGNAL%%</strong>%</div>
</div>
<div style="margin-top:8px;" class="row">
<div>
<div class="label">Uptime</div>
<div id="uptime" class="small">--</div>
</div>
<div style="text-align:right">
<div class="label">Theme</div>
<select onchange="setTheme(this.value)">
<option value="">Dark</option>
<option value="light">Light</option>
</select>
</div>
</div>
</div>
<div style="text-align:center; font-size:12px; color:#9aa0a6; margin-top:8px;">
Auto-refresh every 20s · Live status via JS polling
</div>
</div>
<script>
let poll = true;
let pollHandle = null;
function setTheme(v){
document.body.className = v;
}
function togglePolling(){
poll = !poll;
document.getElementById('pollBtn').textContent = poll ? 'Pause Poll' : 'Resume Poll';
if(poll && !pollHandle) startPolling();
if(!poll && pollHandle){ clearInterval(pollHandle); pollHandle = null; }
}
async function fetchJsonText(path){
try {
const res = await fetch(path, {cache:"no-store"});
if(!res.ok) return null;
return await res.text();
} catch(e){ return null; }
}
async function updateStatus(){
// motion endpoint returns simple text "No Motion" or "Motion Detected at HH:MM:SS"
const motion = await fetchJsonText('/motion');
if(motion !== null){
const el = document.getElementById('motionStatus');
el.textContent = motion;
el.className = motion.includes('Detected') ? 'status red' : 'status green';
}
const last = await fetchJsonText('/lastmotion'); // returns last time string
if(last !== null) document.getElementById('lastMotion').textContent = last;
const cnt = await fetchJsonText('/count');
if(cnt !== null) document.getElementById('motionCount').textContent = cnt;
const hist = await fetchJsonText('/history'); // HTML list items
if(hist !== null) document.getElementById('history').innerHTML = hist;
const sig = await fetchJsonText('/signal');
if(sig !== null){
document.getElementById('signalfill').style.width = sig + '%';
document.getElementById('signalText').textContent = sig;
}
const uptime = await fetchJsonText('/uptime');
if(uptime !== null) document.getElementById('uptime').textContent = uptime;
const now = await fetchJsonText('/time');
if(now !== null) document.getElementById('clock').textContent = now;
}
function startPolling(){
updateStatus();
pollHandle = setInterval(updateStatus, 2000);
}
async function resetData(){
await fetch('/reset');
await updateStatus();
}
// initial
document.addEventListener('DOMContentLoaded', () => {
startPolling();
});
</script>
</body>
</html>
)rawliteral";
// Replace placeholders
html.replace("%LAST_MOTION%", lastMotionTime);
html.replace("%MOTION_COUNT%", String(motionCount));
html.replace("%MOTION_HISTORY%", historyHTML);
html.replace("%SSID%", String(ssid));
html.replace("%IP%", WiFi.localIP().toString());
html.replace("%SIGNAL%", signal);
server.send(200, "text/html", html);
}
void handleMotion() {
String status = motionState ? ("Motion Detected at " + lastMotionTime) : "No Motion";
server.send(200, "text/plain", status);
}
void handleLastMotion() {
server.send(200, "text/plain", lastMotionTime);
}
void handleCount() {
server.send(200, "text/plain", String(motionCount));
}
void handleHistory() {
// return only list items (li) for the page to inject
String hist = buildHistoryHTML();
server.send(200, "text/plain", hist);
}
void handleSignal() {
server.send(200, "text/plain", getSignalQualityPercent());
}
void handleTime() {
// return HH:MM:SS from NTP client
String t = timeClient.getFormattedTime();
server.send(200, "text/plain", t);
}
void handleUptime() {
unsigned long s = (millis() - bootMillis) / 1000;
unsigned long hrs = s / 3600;
unsigned long mins = (s % 3600) / 60;
unsigned long secs = s % 60;
String up = String(hrs) + "h " + String(mins) + "m " + String(secs) + "s";
server.send(200, "text/plain", up);
}
void handleReset() {
motionCount = 0;
lastMotionTime = "None";
for (int i = 0; i < HISTORY_SIZE; i++) motionHistory[i] = "";
historyIndex = 0;
server.send(200, "text/plain", "ok");
}
void handleNotFound(){
server.send(404, "text/plain", "404");
}
// ---------- Setup & Loop ----------
void setup() {
Serial.begin(115200);
delay(10);
bootMillis = millis();
// Try to set static IP; continue even if it fails
if (!WiFi.config(local_IP, gateway, subnet, primaryDNS, secondaryDNS)) {
Serial.println("⚠️ Failed to configure static IP (continuing with DHCP)");
}
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000UL) {
delay(300);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println();
Serial.print("Connected. IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println();
Serial.println("WiFi connect timed out; device will still start server (AP not required).");
}
// start time & server immediately after WiFi attempt
timeClient.begin();
timeClient.update();
// register routes
server.on("/", handleRoot);
server.on("/motion", handleMotion);
server.on("/lastmotion", handleLastMotion);
server.on("/count", handleCount);
server.on("/history", handleHistory);
server.on("/signal", handleSignal);
server.on("/time", handleTime);
server.on("/uptime", handleUptime);
server.on("/reset", handleReset);
server.onNotFound(handleNotFound);
server.begin();
Serial.println("HTTP server started");
// Now initialize PIR
pinMode(PIR_SENSOR_OUTPUT_PIN, INPUT);
pinMode(LED_PIN, OUTPUT);
Serial.println("PIR warming up...");
delay(8000); // warm-up; reduced for faster startup
Serial.println("PIR ready");
}
void loop() {
server.handleClient();
static unsigned long lastNtp = 0;
unsigned long now = millis();
if (now - lastNtp >= 1000) { // update timeClient once per second
timeClient.update();
lastNtp = now;
}
// Read sensor non-blocking
int sensor = digitalRead(PIR_SENSOR_OUTPUT_PIN);
if (sensor == HIGH && !motionState) {
// Motion start
lastMotionTime = timeClient.getFormattedTime();
motionCount++;
motionHistory[historyIndex] = lastMotionTime;
historyIndex = (historyIndex + 1) % HISTORY_SIZE;
digitalWrite(LED_PIN, HIGH);
motionState = true;
Serial.printf("Motion #%d at %s\n", motionCount, lastMotionTime.c_str());
} else if (sensor == LOW && motionState) {
// Motion ended
motionState = false;
digitalWrite(LED_PIN, LOW);
Serial.println("No Motion");
}
// small delay for responsiveness
delay(30);
}Here is the serial terminal response.
You can open up the Ip address and then it will show the system, response.
To keep the build compact and deployment-ready, I housed the Beetle ESP32-C3 and PIR sensor in a custom 3D-printed enclosure designed specifically for this project.
The case was fabricated by JustWay, a maker-friendly manufacturing service that specializes in rapid prototyping for embedded systems and IoT devices.
🎯 Why JustWay?- Precision Fit: Cutouts for USB-C, PIR dome, and PCB mounting points.
- Durable Materials: Printed in PLA with options for ABS and flexible filaments.
- Fast Turnaround: Delivered within days—perfect for prototyping cycles.
- Maker-First Approach: Supports open-source projects and educational builds.
Ordering from JustWay is simple and secure:
- Prepare Your 3D Model Save your design in
.stl,.obj, or.stepformat (under 500MB).
- Visit the JustWay Quote Page Go to the JustWay Instant Quote Portal and select “3D Printing.
- Upload Your Design Click “Upload Your Design” and submit your file. All uploads are encrypted and confidential.
- Customize & Confirm Choose your material, color, and finish. You’ll get an instant quote and estimate delivery time.
- Place Your Order Complete payment and receive your case within 3–7 days across India.
Follow these steps to put the hardware together and get your motion detection system ready:
Prepare the Components
- Beetle ESP32‑C3 board
- PIR motion sensor (HC‑SR501 or equivalent)
- USB‑C cable (for power + programming)
- Custom 3D‑printed enclosure (from JustWay or your own design)
- Jumper wires (female‑to‑female recommended)
Connect the PIR Sensor
- VCC → 3V3 on Beetle ESP32‑C3
- GND → GND
- OUT → GPIO0 (as defined in the firmware)
- Keep the wiring short for stability inside the case.
Fit into the Enclosure
- Place the Beetle ESP32‑C3 into the PCB slot of the 3D‑printed case.
- Align the PIR dome with the circular cutout.
- Route the USB‑C port through its slot for easy access.
- Home security and intrusion alerts
- Office or lab motion logging
- Smart automation triggers (lights, cameras, alarms)
- Classroom demos for IoT and embedded systems
I’m planning to expand this project with:
- MQTT support for local broker integration
- OLED display for status feedback
- Battery-powered version with deep sleep and wake-on-motion
- Telegram bot integration for direct messaging alerts
This compact motion detection system built around the Beetle ESP32-C3 showcases how powerful and expressive embedded projects can be—even with minimal hardware. By combining real-time webhook alerts, a PIR sensor, and a custom 3D-printed enclosure from JustWay, the build becomes not just functional, but deployable and scalable.
Whether you're prototyping for home security, classroom demos, or smart automation, this project offers a modular foundation you can extend with MQTT, cloud dashboards, or battery-powered mobility. And with JustWay’s rapid fabrication services, turning your digital designs into physical enclosures is just a few clicks away.


Comments