Project Overview & Difficulty Rating
Building battery-powered IoT nodes requires mastering microcontroller sleep states. The ESP32 Arduino framework makes this accessible, but mixing WiFi radios, capacitive touch pins, and deep sleep often triggers obscure watchdog resets and upload failures. This guide walks through building a WiFi-enabled soil moisture sensor that wakes, reads, transmits, and sleeps, drawing microamps in standby.
Time to Build: 45 minutes
Target Board Variant: ESP32 DevKit V1 (specifically the ESP32-WROOM-32E module variant, 30-pin or 38-pin). Code is validated against ESP32 Arduino Core v3.0.x.
Exact Parts List
- Microcontroller: ESP32 DevKit V1 (ESP32-WROOM-32E, 4MB Flash). Avoid the older ESP32-WROOM-32D if possible; the 'E' variant has improved RF shielding.
- Sensor: Capacitive Soil Moisture Sensor v1.2 (Must be the capacitive version with the 555 timer IC on the back, not the resistive dual-prong type which corrodes in days).
- Power: 3.7V 18650 Li-ion cell (e.g., Samsung 25R or Molicel P26A) + TP4056 Type-C charging module with DW01A battery protection.
- Passives: 10kΩ pull-up resistor, 100µF electrolytic capacitor (for brownout mitigation across the 3V3 and GND rails).
Pin Mapping & Hardware Wiring
The ESP32 has specific pins routed to the internal capacitive touch controller and the RTC (Real-Time Clock) domain. If you wire a sensor to a standard GPIO, it will not wake the chip from deep sleep, and the pin will leak current.
| ESP32 Pin | Sensor / Component | Function & Notes |
|---|---|---|
| GPIO 32 (Touch 9) | Sensor AOUT | Analog read. Touch-capable pin, remains active in sleep. |
| GPIO 25 (DAC1) | Sensor VCC | Powers sensor. Driven HIGH only during wake to prevent parasitic drain. |
| GND | Sensor GND / TP4056 GND | Common ground reference. |
| 3V3 | TP4056 OUT+ | Main power rail. Add 100µF cap here to stop WiFi brownouts. |
| GPIO 2 | Onboard LED | Status indicator. Pulled LOW to turn on. |
Complete Arduino IDE Code for ESP32
This code targets the ESP32 DevKit V1. It powers up the sensor via a GPIO, takes an averaged analog reading, connects to WiFi, pushes the data via HTTP GET, and enters deep sleep. It includes robust error handling for WiFi timeouts and HTTP failures to prevent the chip from hanging and draining the battery.
#include
#include
#include
// --- PIN DEFINITIONS ---
#define SENSOR_POWER_PIN 25 // Powers the sensor VCC
#define SENSOR_READ_PIN 32 // Analog out from sensor (Touch 9)
#define STATUS_LED_PIN 2 // Onboard LED
// --- NETWORK & TIMING CONFIG ---
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* serverUrl = "http://192.168.1.100/api/moisture";
const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second max connect time
const uint64_t SLEEP_DURATION_US = 3600000000; // 1 hour in microseconds
// Retain boot count across deep sleep cycles
RTC_DATA_ATTR int bootCount = 0;
void setup() {
Serial.begin(115200);
bootCount++;
// Initialize pins
pinMode(SENSOR_POWER_PIN, OUTPUT);
pinMode(STATUS_LED_PIN, OUTPUT);
digitalWrite(SENSOR_POWER_PIN, LOW); // Keep sensor off initially
digitalWrite(STATUS_LED_PIN, HIGH); // LED off (active low on most DevKits)
Serial.printf("Boot #%d starting...\n", bootCount);
// 1. Power up sensor and wait for stabilization
digitalWrite(SENSOR_POWER_PIN, HIGH);
delay(500); // Allow capacitive sensor circuit to stabilize
// 2. Read sensor (oversample to reduce noise)
uint32_t rawSum = 0;
for (int i = 0; i < 16; i++) {
rawSum += analogRead(SENSOR_READ_PIN);
delay(10);
}
uint16_t moistureRaw = rawSum / 16;
Serial.printf("Moisture Raw ADC: %d\n", moistureRaw);
// 3. Connect to WiFi with timeout
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
unsigned long startAttemptTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < WIFI_TIMEOUT_MS) {
delay(250);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi Connected!");
digitalWrite(STATUS_LED_PIN, LOW); // LED ON
sendData(moistureRaw);
digitalWrite(STATUS_LED_PIN, HIGH); // LED OFF
} else {
Serial.println("\nWiFi Connection Timed Out!");
}
// 4. Clean up and sleep
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
digitalWrite(SENSOR_POWER_PIN, LOW); // Cut sensor power
Serial.println("Entering deep sleep...");
esp_sleep_enable_timer_wakeup(SLEEP_DURATION_US);
esp_deep_sleep_start();
}
void sendData(uint16_t value) {
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
String url = String(serverUrl) + "?val=" + String(value) + "&boot=" + String(bootCount);
http.begin(url);
http.setTimeout(5000); // 5 second HTTP timeout
int httpCode = http.GET();
if (httpCode > 0) {
Serial.printf("HTTP GET OK, code: %d\n", httpCode);
} else {
Serial.printf("HTTP GET failed, error: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
}
}
void loop() {
// This will never run because setup() ends with deep_sleep_start()
}
Debugging Common ESP32 Arduino Errors
When working with the ESP32 Arduino core, you will inevitably hit hardware-level panics or bootloader timeouts. Here are the exact error strings and how to fix them.
Error 1: 'Failed to connect to ESP32: Timed out waiting for packet header'
This occurs during the upload phase when the Arduino IDE cannot handshake with the ESP32's ROM bootloader.
- Cause: Charge-only USB cable. This is the #1 culprit. Swap to a verified data-sync cable.
- Cause: Missing UART drivers. If your board uses the CP2102 chip, download the official Silicon Labs CP210x VCP drivers. If it uses CH340, install the WCH CH340 driver.
- Cause: Auto-reset circuit failure. Some clone DevKits have poorly timed RC circuits on the EN and GPIO 0 pins. Fix: Press and hold the
BOOTbutton on the board, click 'Upload' in the IDE, and release theBOOTbutton exactly when the console says 'Connecting...'.
Error 2: 'Guru Meditation Error: Core 1 panic\'ed (Interrupt wdt timeout on CPU1)'
The Watchdog Timer (WDT) resets the chip if a task hogs the CPU without yielding. This usually happens when WiFi operations block the main loop.
- Cause: Blocking I2C/SPI reads during WiFi TX. If you are reading a sensor via I2C while the WiFi radio is transmitting, the RF interrupt starves the CPU. Fix: Read all sensors before calling
WiFi.begin(). - Cause: Infinite loops without yielding. If you use a
while()loop waiting for a condition, you must includeyield();ordelay(1);inside it to feed the watchdog. - Cause: Power supply brownout. The WiFi radio draws spikes of 350mA+. If your USB port or 3.3V regulator sags below 2.8V, the brownout detector triggers a reset that mimics a WDT panic. Fix: Solder a 100µF to 470µF capacitor directly across the 3V3 and GND header pins on the DevKit.
1. Cable & Port: Verify you are using a data cable and have selected the correct COM port (check Device Manager to confirm the port number actually changes when you unplug the board).
2. Board Selection: Ensure you selected 'ESP32 Dev Module' (not ESP32-S3 or ESP32-C3) and set 'Flash Size' to 4MB and 'Partition Scheme' to 'Default 4MB with spiffs' in the Tools menu.
3. Logic Levels: Verify your external sensors are 3.3V tolerant. Feeding 5V into GPIO 32 will permanently damage the ESP32's ADC multiplexer.
Extending and Simplifying the Build
Depending on your deployment environment, you may need to scale this design up or down.
How to Simplify (Bench Testing)
If you are just testing the code on your desk, strip out the deep sleep logic. Replace esp_deep_sleep_start(); with a standard delay(60000); in the loop. Remove the SENSOR_POWER_PIN switching and wire the sensor VCC directly to 3V3. This allows you to monitor the Serial output continuously without the board resetting and dropping the USB connection every minute.
How to Extend (Production Deployment)
For a permanent garden installation, the DevKit's onboard AMS1117-3.3 LDO regulator is highly inefficient, wasting battery life as heat.
- Drop the DevKit: Transition to a raw ESP32-WROOM-32E module or an ultra-low-power board like the FireBeetle ESP32.
- Upgrade Power: Add a high-efficiency DC-DC buck converter (like the TPS62740) instead of an LDO to step down the 18650 voltage.
- Add OTA: Integrate the
ArduinoOTAlibrary so you can push firmware updates over WiFi without digging the node out of the soil.
Frequently Asked Questions (FAQ)
How do I install the ESP32 board manager in Arduino IDE 2.x?
Open Arduino IDE 2.x, go to File > Preferences, and paste https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json into the 'Additional boards manager URLs' field. Next, open the Boards Manager (the icon on the left sidebar), search for 'esp32', and install the official package by Espressif Systems. For detailed schematic and setup references, consult the official Espressif Arduino installation docs.
Why is my ESP32 Arduino code restarting in a boot loop?
A boot loop (constantly printing 'ets_main.c' or 'rst:0x10 (RTCWDT_RTC_RESET)' to the serial monitor) is almost always caused by a power brownout during the WiFi RF calibration phase, or a stack overflow from declaring massive arrays (like large JSON buffers) inside setup() or loop(). Move large buffers to the global scope or allocate them dynamically on the heap using malloc() or std::vector, and ensure your 3.3V rail can supply at least 500mA peak.
Can I use standard Arduino libraries with the ESP32?
Mostly yes, but with caveats. Pure logic libraries (like math parsers or state machines) work perfectly. However, libraries that directly manipulate AVR hardware registers (like older Adafruit_NeoPixel versions or direct PORTB manipulations) will fail to compile. Always check the library's library.properties file or GitHub README to ensure 'esp32' is listed in the architectures field. For I2C devices, note that the ESP32 Arduino core defaults I2C to GPIO 21 (SDA) and GPIO 22 (SCL), unlike the Uno's A4/A5.
What is the difference between ESP32 DevKit v1 and ESP32-S3 for Arduino?
The classic DevKit v1 uses the original dual-core Xtensa LX6 processor. The newer ESP32-S3 features a dual-core Xtensa LX7, includes native USB (no CP2102/CH340 chip needed, eliminating many upload errors), and has vector instructions for AI acceleration. However, the S3 lacks the built-in DAC (Digital-to-Analog Converter) found on the original ESP32. If your code relies on dacWrite() (as some older audio or analog-simulation sketches do), it will not compile on the S3 without modification.






