A low-power, battery-friendly security guard built on an ESP32 DevKitC. The device spends almost all of its time in deep sleep and only wakes on an event: the door opening, movement, a button on a 433 MHz remote, or the timer. When armed and the door opens, it sounds a buzzer and sends a Telegram notification. It can be armed/disarmed and queried with a wireless remote, configured over a web portal, and updated over-the-air — all without a wired connection.
Hardware
| Part | Purpose |
|---|---|
| ESP32 DevKitC | The MCU. Deep sleep, WiFi, PWM for LED/buzzer. |
| Passive piezo buzzer | Audible alarm (driven with tone()). |
| MC-38 magnetic reed switch (wired, NC/NO) | Door-open sensor. Used on its NC contacts. |
| Staniot 433 MHz 4-button keyfob remote | Wireless arm/disarm, status, portal. |
| 433 MHz RF receiver, 4-CH learning-code decoder (1527/2262) | Decodes the keyfob; drives the ESP32 wake inputs. |
| Common-cathode RGB LED + 3 × 220 Ω resistors | Status indication. |
| 10 kΩ resistors | Pull-downs on the active-high wake inputs. |
| (optional) IR / PIR sensor | Alternative trigger in place of (or alongside) the reed switch. |
Wiring
Pin map (defined in include/config.h)
| ESP32 GPIO | Connected to | Direction | Notes |
|---|---|---|---|
| GPIO 14 | MC-38 door reed switch | input, wake | wakeupGpioDoor / doorPin, 10 kΩ pull-down |
| GPIO 27 | RF receiver CH → "toggle" (arm/disarm) | input, wake | wakeupGpioToggle, 10 kΩ pull-down |
| GPIO 4 | RF receiver CH → "status" | input, wake | wakeupGpioStatus, 10 kΩ pull-down |
| GPIO 13 | RF receiver CH → "portal" (config/OTA) | input, wake | wakeupGpioPortal / portalPin, 10 kΩ pull-down |
| GPIO 19 | RGB LED red anode (via resistor) | output (PWM) | redPin, 220 Ω |
| GPIO 22 | RGB LED green anode (via resistor) | output (PWM) | greenPin, 220 Ω |
| GPIO 23 | RGB LED blue anode (via resistor) | output (PWM) | bluePin, 220 Ω |
| GPIO 21 | Piezo buzzer (+) | output (PWM) | buzzerPin |
The key electrical principle: everything wakes on HIGH
The ESP32 wakes from sleep using EXT1 with ESP_EXT1_WAKEUP_ANY_HIGH — i.e. it wakes
when any of GPIO 14/27/4/13 goes HIGH. So every wake input must:
- idle LOW (hence the 10 kΩ pull-downs to GND), and
- be driven HIGH when its event happens.
GPIO 14, 27, 4 and 13 are all RTC-capable pins (required for EXT1 wakeup) and none are boot-strapping pins, so they don't interfere with the ESP32's boot mode.
Door sensor (MC-38, normally-closed) — GPIO 14
The reed switch and a 10 kΩ pull-down form a simple high/low divider:
3.3V ──[ MC-38 reed (NC contacts) ]──┬── GPIO14
│
[10kΩ]
│
GND
Using the NC contacts of the reed (closed when no magnet is present):
- Door closed → magnet is next to the reed → contacts open → GPIO14 pulled LOW by the 10 kΩ.
- Door open → magnet moves away → contacts close → GPIO14 sees 3.3 V = HIGH → wakes the ESP32 and reads as "door open".
This matches the firmware, where digitalRead(doorPin) == HIGH means open.
Alternative trigger: an IR beam-break or PIR sensor can replace the reed switch on GPIO 14 instead. Wire it so its output is HIGH on detection (and LOW idle); the rest of the firmware is unchanged.
433 MHz remote + RF receiver — GPIO 27 / 4 / 13
The keyfob's buttons are decoded by the 4-channel 1527/2262 receiver, whose channel outputs go HIGH while a button is pressed. Set the receiver to momentary mode (jumper), and wire three of its channels to the wake inputs:
| Remote button → RF channel | ESP32 pin | Action |
|---|---|---|
| Button 1 | GPIO 27 (toggle) | Arm / disarm the alarm |
| Button 2 | GPIO 4 (status) | Show current state on the LED |
| Button 3 | GPIO 13 (portal) | Enter the configuration / OTA portal |
| Button 4 | (spare) | unused (or wire to GPIO 14 to test the door path) |
The receiver actively drives its outputs LOW when idle, so the 10 kΩ pull-downs are belt-and- braces (and protect against a floating pin while the receiver powers up).
RGB LED — GPIO 19 / 22 / 23
A common-cathode RGB LED: the common pin to GND, and each color leg through its own
current-limiting resistor (220 Ω) to GPIO 19 (R), 22 (G), 23 (B). The firmware drives
them with PWM (analogWrite). Colors used:
| Color | Meaning |
|---|---|
| Blue | Idle / "I'm awake" indicator, also blinks during portal mode |
| Red | Armed (status) / counting down the disarm window |
| Red blink | counting down the disarm window |
| Green | Disarmed (status) |
| Red ↔ Blue blink | "Pending" temporary state during a timed armed phase (shown when you press status mid-wait) |
| Off | Sleeping, or stealth mode |
If you use a common-anode LED instead, the colors will be inverted — tie the common to 3.3 V and expect to flip the logic.
Buzzer — GPIO 21
A passive piezo buzzer: one terminal to GPIO 21, the other to GND. The firmware generates
the tone in software with tone(buzzerPin, frequency, duration) (default 2 kHz). For louder
output, drive the buzzer through a transistor rather than directly from the GPIO.
How it works
The device is event-driven around deep sleep:
- Sleeping (deep sleep) — waiting for a wake source.
- Wake via EXT1 (door / toggle / status / portal) or the RTC timer, then it acts on the
cause and goes back to sleep:
- Toggle → arm or disarm. On arming it shows status, then enters a grace period (default 30 min) before reporting the door state over Telegram.
- Status → show armed/disarmed on the LED.
- Portal → cancel the alarm and open the web portal (see below).
- Door (only watched while armed) → start a disarm window (default 8 s, red LED blinking). If you don't disarm in time, it sounds the buzzer, sends a Telegram alert, and starts a cooldown.
- Timer → a timed phase came due (see below).
- Auto temporary-disable — after a configurable number of triggers (
maxAlarmTriggers, default 3), the alarm temporarily disables itself for a day (configurable) and notifies you, then re-arms automatically via the RTC timer.
The multi-minute "armed but waiting" periods (the post-arm grace period and the post-trigger cooldown) are modelled as timed phases: instead of staying awake, the device records the phase + a deadline in RTC memory and goes back to deep sleep, waking on the RTC timer when the phase is due or earlier on a button. See below.
Power consumption (deep sleep everywhere)
Low power is the whole point of the design, so the device is in deep sleep essentially all the time — including during the long "armed but waiting" periods. There are only two regimes (figures are typical ESP32 ballparks, not measured on this board):
| State | Rough current | When |
|---|---|---|
| Deep sleep | ~10 µA | The default idle state and the timed phases (grace / cooldown / temporary-disable). RAM is powered down; only the RTC + EXT1 wake logic stays alive. |
| Active (WiFi) | ~80–160 mA | Only briefly: connecting WiFi to send a Telegram message, window to disarm the alarm, or while the web portal is up. |
How the timed waits stay in deep sleep
Deep sleep (esp_deep_sleep_start) is the lowest power, but it loses RAM and restarts from
setup() on wake. So state that must survive is kept in RTC memory (RTC_DATA_ATTR): the
temporary-disable flag, the trigger counter, and the current phase (None / Grace /
Cooldown / TempDisabled) plus its deadline.
Each timed wait is just a phase with a deadline:
- The action that starts a wait (e.g. arming) records the phase and
deadline = now + duration, then the device deep-sleeps with the RTC timer set to the remaining time and the buttons (toggle/status/portal) still armed as EXT1 wake sources. - Timer wake → the phase is due, so its follow-up runs (grace → report door state; cooldown → maybe escalate to temporary-disable; temporary-disable → re-arm), then the device sleeps again.
- Button wake (early) → handle it and recompute the remaining time from the deadline, so an interruption doesn't reset the countdown. Pressing toggle disarms and cancels the phase; portal cancels the alarm and opens the portal; status shows the pending blink and the wait resumes.
The deadline uses a clock that ESP-IDF keeps advancing across deep sleep, so the timing is accurate even across several button wakes (subject to the RTC oscillator's drift, same as any deep-sleep timer).
The only thing that stays awake is the short 8 s disarm window after the door opens — it's brief, time-critical, and blinks the LED, so it runs as a normal awake loop.
Web portal (configuration + OTA updates)
All configuration — WiFi, Telegram, settings, and firmware updates — is done through a built-in
web portal, so a prebuilt firmware.bin can be shared and each person sets their own
credentials with no rebuild and no secrets baked into the binary.
The portal opens in two ways:
- Automatically on first run — if the required credentials are missing, the device boots straight into the portal (see below).
- On demand — press the portal button (GPIO 13) any time, even during a timed armed phase (which cancels the alarm first).
When it opens, the device starts a WiFi Access Point at http://10.10.10.1/ and runs a
small web server for a bounded window (default 5 minutes, configurable). It's a local
portal: once you join the AP, the configuration menu pops up automatically (the device
answers all DNS and redirects unknown URLs to the menu). The blue LED blinks while it's open;
press the portal button again to exit early, otherwise it closes when the window elapses.
First-time setup (fresh / unconfigured device)
A freshly-flashed device has no credentials, so on boot it starts a setup Access Point with built-in defaults:
| SSID | Smart |
| Password | configure |
- Flash
firmware.binand power the device. - Join the
SmartWiFi from your phone/laptop — the menu should auto-open (or browse tohttp://10.10.10.1/). - Open Configure WiFi & Telegram, choose Home (WPA2-PSK) or Enterprise, fill in your network + Telegram bot token/chat ID, and save.
- Power-cycle. The device now connects to your network and runs as an alarm. (You can change the setup-AP SSID/password from that same form.)
Web routes
| URL | Page |
|---|---|
/ |
Menu — links to the pages below. |
/credentials · /credentials/save |
WiFi & Telegram — network type (PSK/Enterprise), SSID, password/EAP fields, Telegram token + chat ID, setup-AP credentials, optional MAC. Password/token fields are write-only (blank = keep current). |
/settings · /settings/save |
Settings — stealth mode + all timing values, in friendly units. Applied immediately (portal-window length applies next session). |
/update |
Firmware update (OTA) — upload a new firmware.bin (served by ESP2SOTA). |
Internally the web_portal module owns the AP + web server (+ captive-portal DNS) and composes
the web_credentials form, the web_config settings form, and the ESP2SOTA /update endpoint.
Telegram notifications
The device reports events to a Telegram chat over your WiFi network — either WPA2-PSK (home WiFi) or WPA2-Enterprise, selected in the credentials form.
- WiFi is connected on demand (with a connect timeout, so a misconfigured device doesn't hang),
then messages are sent via
UniversalTelegramBotover TLS. - Messages you'll receive:
The Door has been opened— alarm tripped (after the disarm window expired).Door is open/Door is closed— door state reported at the end of the arming grace period.The alarm has been temporarily deactivated— after too many triggers.The alarm has been re-activated after temporal deactivation— auto re-arm.Exiting portal mode— left the web portal early.
Credentials
Credentials live in the ESP32's NVS (flash key-value store), namespace credentials —
nothing is hard-coded in the firmware. You set them through the portal (above), not by
editing source. The fields:
| Key | Required? | Meaning |
|---|---|---|
WIFI_SSID |
Required | Your WiFi network name (PSK or enterprise). |
WIFI_PASSWORD |
Required for PSK | Home WiFi password. |
EAP_IDENTITY, EAP_USERNAME, EAP_PASSWORD |
Required for Enterprise | WPA2-Enterprise (PEAP/TTLS) credentials. |
BOT_TOKEN |
Required | Telegram bot token. |
CHAT_ID |
Required | Telegram chat ID to notify. |
OTA_SSID, OTA_PASSWORD |
Optional | Setup-AP credentials; fall back to the built-in defaults (Smart / configure) when unset. |
newMACAddress (6 bytes) |
Optional | MAC address to spoof for the station interface; applied only if set. |
A device is considered configured once it has a WiFi SSID, the matching WiFi secret (PSK password or EAP username+password), and both Telegram fields. Until then it boots into the setup portal automatically.
NVS is not encrypted by default — fine for a personal project. If you need the stored secrets protected against a flash dump, enable ESP32 flash + NVS encryption.
Configurable settings
Editable from the web portal; defaults live in include/config.h and are stored in the NVS
settings namespace.
| Setting | Default | Description |
|---|---|---|
| Stealth mode | off | When on, the LED stays dark and the buzzer is silent. |
| Deactivation window | 8 s | Time to disarm after the door opens before the alarm sounds. |
| LED blink interval | 500 ms | Blink rate during the disarm window. |
| Post-activation delay | 30 min | Grace period after arming before reporting door state. |
| Post-trigger delay | 20 min | Cooldown after the alarm sounds. |
| Temporary-disable period | 24 h | How long the alarm stays auto-disabled after too many triggers. |
| Portal window | 5 min | How long the web portal stays open. |
Repository layout
A monorepo: a shared library core consumed by independently-buildable per-device firmwares.
shared/ shared across devices
espnow_protocol/ ESP-NOW message contract (+ MESH_CHANNEL)
connectivity/ WiFi (PSK/Enterprise) + Telegram
credentials/ NVS credentials + setup-AP defaults + isConfigured()
firmware/
alarm/ the alarm node (this README's main subject)
orchestrator/ ESP-NOW gateway (receives sensor messages, takes actions)
weather/ weather-sensor node
Each firmware is its own PlatformIO project (Espressif32 / Arduino,
arduino-esp32 2.0.17) and pulls in the shared libs via lib_extra_dirs = ../../shared. A given
firmware only compiles the shared libs it actually includes.
Build & flash
Build a device by entering its folder:
cd firmware/alarm # or firmware/orchestrator, firmware/weather
pio run # build
pio run -t upload # build + flash over USB
pio device monitor # serial monitor (115200 baud)
The alarm's compiled app is at firmware/alarm/.pio/build/esp32dev/firmware.bin — the file you
upload via the web portal's /update page for over-the-air updates.
Firmware architecture (alarm node)
The alarm firmware is split into focused PlatformIO libraries; firmware/alarm/src/main.cpp
just wires them together in setup(). Shared libs live in shared/.
| Module | Responsibility |
|---|---|
config.h (alarm include/) |
Pin map, default timings, buzzer settings, mesh node id |
espnow_protocol (shared) |
ESP-NOW message layout + link channel |
credentials (shared) |
NVS credentials (WiFi PSK/Enterprise, Telegram, setup-AP, mesh, optional MAC) + isConfigured() |
connectivity (shared) |
WiFi (PSK or WPA2-Enterprise) + Telegram messaging, with a connect timeout |
rgb_led |
RGB status LED (incl. stealth gating and sleep-safe off via GPIO hold) |
buzzer |
Piezo buzzer tones (stealth-aware) |
mesh |
ESP-NOW sender to the orchestrator (mesh mode) |
notifier |
Mode-aware reporting: mesh (ESP-NOW) vs standalone (Telegram), with fallback |
web_portal |
Temporary SoftAP + web server + captive-portal DNS; hosts the menu + OTA |
web_credentials |
The credentials form routes (/credentials, /credentials/save) |
web_config |
The settings form routes (/settings, /settings/save) |
settings |
Runtime-configurable settings, persisted in NVS |
alarm |
Alarm state, arm/disarm, status display, and the timed phase machine (grace / cooldown / temporary-disable) backed by RTC memory |
deep_sleep |
Wake-cause dispatch + phase-aware deep-sleep configuration |
| Module | Responsibility |
|---|---|
config.h (in include/) |
Pin map, default timings, buzzer settings, setup-AP defaults |
rgb_led |
RGB status LED (incl. stealth gating and sleep-safe off via GPIO hold) |
buzzer |
Piezo buzzer tones (stealth-aware) |
credentials |
Single source for the NVS credentials namespace (WiFi PSK/Enterprise, Telegram, setup-AP, optional MAC) + isConfigured() |
connectivity |
WiFi (PSK or WPA2-Enterprise) + Telegram messaging, with a connect timeout |
web_portal |
Temporary SoftAP + web server + captive-portal DNS; hosts the menu and composes the forms + OTA |
web_credentials |
The credentials form routes (/credentials, /credentials/save) |
web_config |
The settings form routes (/settings, /settings/save) |
settings |
Runtime-configurable settings, persisted in NVS |
alarm |
Alarm state, arm/disarm, status display, and the timed phase machine (grace / cooldown / temporary-disable) backed by RTC memory |
deep_sleep |
Wake-cause dispatch + phase-aware deep-sleep configuration |
Comments