What You Will Build
An educational motion-input notifier that sends a short text to your own private Telegram chat. It waits through sensor warm-up and a stable LOW, detects a new HIGH, keeps at most one pending event and limits cloud attempts. It adds an actual HTTPS notification workflow to the existing local PIR lesson; it is not a verified intrusion alarm or person detector.
Before You Start
Classic ESP32-WROOM DevKit, Arduino IDE 2.x and Arduino-ESP32 3.3.2 as the review baseline, not a latest-release claim. Select ESP32 Dev Module only for the stated generic WROOM board. Other families need their own pin/radio review. Complete the Serial-only IDE upload first. The complete sketches below were reviewed against documentation; they have not been compiled or tested on physical hardware. The documented Adafruit PIR #189 supplies a 3.3 V digital output when powered from its specified supply. Generic HC-SR501 clones may differ in regulator, pin order, hold time and output voltage; they are not silently interchangeable. Check your own board before connecting OUT. No motor, relay or sound output is driven.
Parts
- Classic ESP32-WROOM DevKit2.4 GHz STA Wi-Fi and USB power.
- Adafruit PIR module #189Documented 5–12 V supply and 3.3 V output. This example selects external regulated 5 V.
- Regulated 5 V low-voltage supplyDocumented module polarity; share minus with ESP32 GND, not USB positive.
- Telegram bot and private chatUser-owned bot token, positive numeric private-chat ID and internet access. No account or secret is created by this tutorial.
Wiring
Identify the selected PIR connector functions from its supplier pinout; do not infer VCC/OUT/GND from a generic photo or wire color. Connect module positive to external regulated 5 V, its ground to supply minus and ESP32 GND, and its documented 3.3 V OUT to GPIO27. ESP32 remains USB-powered. Do not join external positive to USB/3V3. The internal input pull-down defines an open input but cannot detect a disconnected or faulty sensor. If using another module, verify its OUT level first; never put a 5 V signal into GPIO27.
-
1
With USB and external power off, identify PIR supply, ground and OUT from the exact module documentation.
-
2
Wire the table, verify separate positive rails and common ground, then set the documented PIR to its appropriate retrigger mode and short hold delay for bench tests.
-
3
Power the sensor and ESP32, and confirm warm-up/LOW/HIGH behavior in Serial before relying on network notification.
-
4
Install ArduinoJson 6.21.5 and fill the four placeholders privately; upload with Serial at 115200.
-
5
Wait 60 seconds plus at least two seconds of stable LOW, then create a new motion-input HIGH. Observe Serial API acceptance separately from the phone notification.
Wiring Reference
| Signal | ESP32 Pin | Direction | Notes |
|---|---|---|---|
| Selected PIR + supply | Regulated external 5 V | Power | Adafruit #189, not an unknown clone |
| PIR GND | 5 V minus + ESP32 GND | Ground | Share ground; do not join positive rails |
| PIR OUT | GPIO27 | Digital input | Documented 3.3 V output; verify module |
| ESP32 power | USB data cable | Power | No relay, buzzer or actuator attached |
Libraries and Setup
- Arduino-ESP32 3.3.2: built-in WiFi, NetworkClientSecure, HTTPClient and time.
- ArduinoJson 6.21.5. This example calls the Telegram HTTPS API directly; UniversalTelegramBot is not required.
- Your authorized 2.4 GHz Wi-Fi with internet DNS, HTTPS and network-time access. Captive-portal/enterprise onboarding is not implemented.
Configure the Example
Create a bot using Telegram’s official BotFather workflow, send /start to that bot from your own private chat, and obtain that chat’s numeric ID through the official getUpdates API. Its request uses the bot-token-specific getUpdates endpoint at api.telegram.org; keep the token/request/response private and do not use third-party ID collection bots. Replace REPLACE_WIFI_SSID, REPLACE_WIFI_PASSWORD, REPLACE_BOT_TOKEN and REPLACE_PRIVATE_CHAT_ID locally. The sketch deliberately accepts positive numeric private-chat IDs only; groups/channels/webhooks are outside this build. Keep the filled sketch out of a public repository and redact Serial/screenshots; rotate a leaked token through BotFather. With placeholders unchanged no cloud request is sent. No real token or message was used during authoring. Set Arduino IDE Core Debug Level to None; framework HTTP/TLS debug logs can reveal request details even when the sketch does not print them. If sensor power is turned on later than board boot, reset the ESP32 so the full warm-up applies.
Control and Recovery Behavior
| Condition | Result |
|---|---|
| Sensor warm-up / rearm | Ignores events for 60 seconds after boot, then requires LOW for two seconds before a debounced HIGH can queue an event. A HIGH held at startup cannot trigger the first alert. |
| Network unavailable / cooldown | One event remains pending for at most 30 seconds, then is discarded. No disk backlog/replay. Additional edges coalesce. Attempts are at least 60 seconds apart; edges during cooldown may expire. |
| Wi-Fi and clock | Tries Wi-Fi for 15 seconds, disconnects and pauses 30 seconds before another attempt. No endless connection wait. HTTPS sending waits for a plausible network-synchronized clock. |
| HTTPS / API result | Uses a CA trust anchor and hostname validation, not setInsecure. HTTP 200 plus JSON ok=true is API acceptance only; failure/unknown results are not automatically resent. |
| Rate limit / reset | HTTP 429 pauses all further sending until manual diagnosis and reset. Reset clears the RAM state and local cooldown; it does not cancel Telegram service limits. |
Code
Install the libraries above and match the configuration and wiring before uploading.
#include <Arduino.h>
// Keep Arduino IDE Core Debug Level set to None; redact credentials from all logs.
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <string.h>
const char* WIFI_SSID = "REPLACE_WIFI_SSID";
const char* WIFI_PASSWORD = "REPLACE_WIFI_PASSWORD";
const char* BOT_TOKEN = "REPLACE_BOT_TOKEN";
const char* CHAT_ID = "REPLACE_PRIVATE_CHAT_ID";
constexpr uint8_t PIR_PIN = 27;
constexpr uint32_t WARMUP_MS = 60000, LOW_REARM_MS = 2000;
constexpr uint32_t COOLDOWN_MS = 60000, EVENT_MAX_AGE_MS = 30000;
constexpr uint32_t WIFI_TRY_MS = 15000, WIFI_PAUSE_MS = 30000;
// Public Go Daddy Root Certificate Authority - G2, not a private key.
// Reviewed CA source: https://certs.godaddy.com/repository/gdroot-g2.crt
// Confirm the service trust chain when maintaining this example; never use setInsecure().
const char ROOT_CA[] PROGMEM = R"CERT(
-----BEGIN CERTIFICATE-----
MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx
EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT
EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp
ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz
NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH
EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE
AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD
E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH
/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy
DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh
GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR
tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA
AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX
WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu
9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr
gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo
2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO
LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
4uJEvlz36hz1
-----END CERTIFICATE-----
)CERT";
bool warmed = false;
bool configured = false, wifiTrying = false, wasConnected = false;
bool rawHigh = false, stableHigh = false, readyForEdge = false, pending = false;
bool attempted = false, rateLimited = false, lowTiming = false;
uint32_t wifiSince=0, wifiEnded=0, changedAt=0, lowSince=0, eventAt=0, lastAttempt=0;
bool credentialsReady() {
if (strstr(WIFI_SSID,"REPLACE_") || strstr(WIFI_PASSWORD,"REPLACE_") ||
strstr(BOT_TOKEN,"REPLACE_") || !strchr(BOT_TOKEN,':')) return false;
size_t n = strlen(CHAT_ID);
if (!n || n > 20) return false;
for (size_t i=0;i<n;++i) if (CHAT_ID[i]<'0' || CHAT_ID[i]>'9') return false;
return true; // Private-chat numeric ID only; no group/channel support in this example.
}
void startWiFi(uint32_t now) {
WiFi.begin(WIFI_SSID,WIFI_PASSWORD);wifiTrying=true;wifiSince=now;
Serial.println("Starting bounded Wi-Fi attempt (credentials hidden)");
}
void serviceWiFi(uint32_t now) {
if (!configured) return;
if (WiFi.status()==WL_CONNECTED) {
if (!wasConnected) {
wasConnected=true;wifiTrying=false;
configTime(0,0,"pool.ntp.org","time.nist.gov");
Serial.println("Wi-Fi connected; TLS waits for plausible network time");
}
return;
}
if (wasConnected) { wasConnected=false;wifiTrying=false;wifiEnded=now; }
if (wifiTrying && now-wifiSince>=WIFI_TRY_MS) {
WiFi.disconnect();wifiTrying=false;wifiEnded=now;
Serial.println("Wi-Fi attempt ended; wait 30 s before next attempt");
} else if (!wifiTrying && now-wifiEnded>=WIFI_PAUSE_MS) startWiFi(now);
}
void servicePIR(uint32_t now) {
bool high = digitalRead(PIR_PIN)==HIGH;
if (high!=rawHigh) { rawHigh=high;changedAt=now; }
if (now-changedAt>=30) stableHigh=rawHigh;
if (!warmed) {
if (now>=WARMUP_MS) warmed=true;
else { readyForEdge=false;lowTiming=false;return; }
}
if (!stableHigh) {
if (!lowTiming) { lowTiming=true;lowSince=now; }
if (now-lowSince>=LOW_REARM_MS) readyForEdge=true;
} else {
lowTiming=false;
if (readyForEdge) {
readyForEdge=false;
if (pending) Serial.println("Additional edge coalesced; one event already pending");
else { pending=true;eventAt=now;Serial.println("Motion-input event queued (not proof of a person)"); }
}
}
}
bool sendAlert(uint32_t eventUptime) {
NetworkClientSecure tls; tls.setCACert(ROOT_CA);
tls.setHandshakeTimeout(8);tls.setTimeout(5000);
HTTPClient http;http.useHTTP10(true);http.setReuse(false);
http.setConnectTimeout(5000);http.setTimeout(5000);
String url=String("https://api.telegram.org/bot")+BOT_TOKEN+"/sendMessage";
if (!http.begin(tls,url)) return false; // Never print URL/token/response body.
StaticJsonDocument<384> outgoing;
outgoing["chat_id"]=CHAT_ID;
outgoing["text"]=String("Educational PIR input event. Board uptime at detection: ")+String(eventUptime/1000)+" s. Not a verified security alarm.";
String body;serializeJson(outgoing,body);
http.addHeader("Content-Type","application/json");
int code=http.POST(body);
StaticJsonDocument<128> filter;filter["ok"]=true;
StaticJsonDocument<128> answer;
bool accepted=false;
if (code==200) {
auto error=deserializeJson(answer,http.getStream(),DeserializationOption::Filter(filter),DeserializationOption::NestingLimit(12));
accepted=!error && answer["ok"].is<bool>() && answer["ok"].as<bool>();
}
if (code==429) { rateLimited=true;Serial.println("HTTP 429: sending paused until reset; inspect Telegram rate limits before restarting"); }
Serial.printf("Telegram HTTP status: %d; API accepted: %s\n",code,accepted?"yes":"no/unknown");
http.end();tls.stop();return accepted;
}
void setup() {
Serial.begin(115200);pinMode(PIR_PIN,INPUT_PULLDOWN);
configured=credentialsReady();
WiFi.mode(WIFI_STA);WiFi.setAutoReconnect(false);
Serial.println("PIR warm-up: 60 s, then require LOW for 2 s. Network calls can miss short input pulses.");
if (configured) startWiFi(millis());
else Serial.println("Replace credentials and positive private chat ID; no cloud requests while placeholders remain");
}
void loop() {
uint32_t now=millis();servicePIR(now);serviceWiFi(now);
if (pending && now-eventAt>EVENT_MAX_AGE_MS) { pending=false;Serial.println("Queued event expired after 30 s; no replay"); }
bool cooldownOver=!attempted || now-lastAttempt>=COOLDOWN_MS;
bool timeReady=time(nullptr)>1700000000; // Plausibility gate, not proof of clock accuracy.
if (pending && configured && !rateLimited && WiFi.status()==WL_CONNECTED && timeReady && cooldownOver) {
uint32_t captured=eventAt;pending=false;attempted=true;lastAttempt=now;
bool accepted=sendAlert(captured); // Synchronous, bounded per-operation timeouts; not a hard total deadline.
Serial.println(accepted?"API acceptance is not proof of delivery/read":"Outcome failed/unknown; no automatic resend of this event");
}
delay(5);
}
How the Code Works
The loop services a 30 ms stable-level filter and LOW rearm before attempting any send. A pending event stores the detection uptime, not a claimed UTC occurrence time. Wi-Fi attempts are scheduled without an endless blocking wait. Each eligible event is removed before a synchronous HTTPS POST, so an ambiguous response cannot trigger automatic duplicate resends. ArduinoJson generates the request text and filters the reply down to its boolean ok field. HTTP/1.0 avoids unsupported raw chunk parsing when reading HTTPClient’s stream. The sketch does not print token-bearing URLs or reply bodies; framework debug logs must stay disabled and any diagnostics must be redacted. TLS handshake/connect/read timeouts limit individual operations, not the total loop latency; DNS/network work can still delay sampling and short PIR pulses may be missed.
Expected Output
On a configured bench, Serial should describe warm-up, Wi-Fi state and queued events. With internet, plausible time and a trusted Telegram certificate chain, a send should return API acceptance and your private chat may receive the text. Neither HTTP success nor a PIR HIGH proves a person, notification delivery, display or reading. This is a procedure to verify on your own hardware, not a recorded successful cloud test.
Troubleshooting
- No first alert Wait through warm-up and two seconds of LOW. The sensor hold timer/retriggering may keep OUT HIGH; a new HIGH after LOW is required. Check the exact module and Serial state.
- No cloud requests Replace all placeholders and use a positive private-chat ID; check router band, DNS, HTTPS and network time. Events older than 30 seconds are intentionally dropped.
- TLS or HTTP error Check the clock and documented current service trust chain. The embedded public Go Daddy G2 root is a reviewed trust anchor, not a guarantee of a future Telegram certificate chain. Do not bypass verification with setInsecure.
- HTTP 400, 401 or 403 Check bot token, own private-chat ID, /start and whether the bot was blocked. Never paste your token/whole response into public bug reports.
- HTTP 429 or repeated failure Stop testing rapidly. Sending is latched off for this boot after 429; review Telegram retry/rate-limit guidance before resetting. The sketch does not retry an ambiguous event.
- Phone shows no notification after API acceptance Check Telegram chat history and phone notification settings separately. API acceptance is not delivery or read acknowledgement.
- Short events missing TLS/HTTP calls are synchronous and can occupy seconds. This polling demonstration is not an interrupt-backed or reliable alarm/event recorder.
Checks to Perform on Your Build
- First verify the PIR signal and documented voltage without trusting Telegram as a sensor diagnostic.
- Confirm placeholders suppress outbound requests; never use real credentials in screenshots/source control.
- Create one edge after LOW rearm, inspect API status, and check the actual private chat separately.
- Verify sustained HIGH does not create an endless alert stream and attempts are rate-limited.
- Test router unavailability and expired pending events; restoration must not replay a stored backlog.
- Use your own authorized environment to check certificate/time failure stays fail-closed. Do not disable TLS verification to make a test pass.
Practical Design Notes
The 60-second warm-up and LOW interval are chosen bench policies for the documented PIR, not universal sensor readiness proofs. Time plausibility is not a clock-accuracy or authenticated-time guarantee. The public Go Daddy Root CA G2 certificate is valid through 2037, but service trust chains can change earlier; maintain the actual trusted root using current official evidence. Credentials stored in ordinary firmware are not protected against a reader of flash. Message text and chat metadata go to Telegram; only send information you intend to share. The local rate limiter is RAM-only, not a global service quota or protection against reboot loops. No certified alarm, person classification, durable queue, exactly-once delivery or remote actuator control is implemented.
Review, Testing, and References
Author: Abdul Mubeen and the ESP32 Engine editorial team. Last updated: 2026-10-24. Reviewed: wiring logic, Arduino code structure, beginner safety, and learning sequence.
Educational level: Intermediate. Estimated completion time: Varies by build. This project is for learning and prototyping; production or unattended hardware needs additional engineering review.
- Author & Editorial Team
- Editorial Policy
- Testing Methodology
- Espressif ESP32 Documentation
- Arduino ESP32 Core
- Arduino-ESP32 3.3.2 Wi-Fi interfaces
- Classic ESP32 datasheet
- Adafruit PIR module #189 electrical details
- Adafruit PIR stabilization and test procedure
- Telegram bot setup
- Telegram Bot API sendMessage and response contract
- Telegram Bot API getUpdates
- ArduinoJson 6.21.5
- ArduinoJson 6 HTTPClient stream parsing
- NetworkClientSecure at Arduino-ESP32 3.3.2
- HTTPClient at Arduino-ESP32 3.3.2
- Go Daddy Root Certificate Authority - G2 public certificate
