ESP32 Wi-Fi smart plug: relay control over a web interface, momentary physical button, 16x2 I2C LCD status display, and captive-portal Wi-Fi provisioning. Built on ESP-IDF (no Arduino dependency).
This is a feature-identical port of the original Arduino sketch, kept
for reference in assets/cplug.ino.
- Relay toggle over HTTP (single-page web UI, XHR, no reload).
- Momentary physical button: relay ON while held, OFF on release; web toggling is ignored while the button is held.
- Captive portal provisioning: on first boot (or when the stored
network is unreachable) the device opens the WPA2 access point
CPLUG1; any DNS lookup resolves to the device and unknown URLs redirect to the configuration page, so the portal opens automatically on phones and laptops. - Credentials persisted in NVS; they survive reflashing.
- 16x2 LCD: shows the management IP when reachable, otherwise the provisioning AP credentials.
- Fixed station MAC address and hostname (
cplug1). - The device keeps retrying the stored network in the background even while the provisioning AP is active.
| Function | GPIO |
|---|---|
| Relay output | 15 |
| Push button | 4 (active low, internal pull-up) |
| LCD I2C SDA | 21 |
| LCD I2C SCL | 22 |
LCD: HD44780 16x2 with PCF8574 I2C backpack at address 0x27.
Target: ESP32 (WROOM-32 class module, 4 MB flash).
All pins, network identity, and AP credentials are defined in
main/app_config.h.
Wiring diagram: img/cplug_diagram_clear.png.
A 3D-printable case is available in assets/
(cplug_case.stl, cplug_lid.stl).
| Route | Method | Effect |
|---|---|---|
/ |
GET | Control page (HTML) |
/toggle |
GET | Invert relay (unless button held); returns 1/0 |
/config |
GET | Wi-Fi configuration page (HTML) |
/save |
POST | Store ssid/password in NVS, then reboot |
| any other | * | 302 redirect to /config (captive portal) |
- On boot, stored credentials are read from NVS (namespace
wifi). - If present, the device tries to join the network for 10 seconds.
- On failure (or missing credentials) it starts the access point
CPLUG1(seeapp_config.hfor the password) at192.168.4.1, with a catch-all DNS server: connecting to the AP pops up the configuration page automatically (captive portal). - Submit SSID and password; the device reboots and joins the configured network. The LCD then shows the management IP.
Requires ESP-IDF v5.2 or
later (uses the i2c_master driver API).
. $IDF_PATH/export.sh # or: get_idf, depending on your setup
idf.py set-target esp32
idf.py build
idf.py -p /dev/ttyUSB0 flash monitormain/ application (entry point, Wi-Fi, HTTP, relay)
components/lcd1602_i2c/ HD44780/PCF8574 LCD driver component
components/dns_server/ captive-portal DNS server component
tests/host/ host-side unit tests (no hardware required)
assets/cplug.ino original Arduino sketch (the port's reference)
assets/esp32s_relay.ino divergent sketch variant (adds /hold and /5sec
momentary routes, no captive portal) kept in
case those features are wanted later
assets/*.stl 3D-printable case
img/ photos and wiring diagram
sdkconfig.defaults build defaults (4 MB flash, large app partition)
.github/workflows/ CI (lint, host tests, firmware build)
CI runs three checks on every push and pull request; run them locally with:
# Code style (clang-format 22, pinned in CI; config in .clang-format)
find main components tests -name '*.c' -o -name '*.h' \
| xargs clang-format --dry-run --Werror
# Static analysis
cppcheck --enable=warning,style,performance,portability \
--error-exitcode=1 --inline-suppr --check-level=exhaustive \
--suppress=missingIncludeSystem \
-I main -I components/lcd1602_i2c/include \
-I components/dns_server/include main components tests
# Host unit tests
gcc -Wall -Wextra -Werror -I main \
-o test_url_decode main/url_decode.c tests/host/test_url_decode.c
./test_url_decodeDocumentation-only changes (*.md, assets/, img/, LICENSE) skip CI.
Two deliberate deviations, both improvements; everything else is feature-identical:
- The stored Wi-Fi password is never echoed to the serial console (the sketch printed it after saving).
- The captive-portal DNS server only runs while the provisioning AP is
active (the sketch also bound UDP port 53 when connected as a
station, answering with a useless
0.0.0.0).
- The web interface is unauthenticated HTTP, intended for a trusted LAN.
- The provisioning AP password is compiled into the firmware and shown on the LCD while the AP is active.
