ESP-IDF code patterns: OTA, diagnostics, and power management
You've set up your project structure and partition table. Now what? The next layer is the actual firmware code — the patterns that keep your device running reliably. I've learned these through deployed devices in the field: solar-powered sensors that need to run for months, OTA updates that can brick a device if they stall, and the peculiar debugging nightmare of a device you can't reach without logging in to a VPN jump host.
OTA update strategies: when to check and how to download
OTA updates are great until they're not. The first time a firmware update stalls halfway through (on a slow cellular connection, say) and the device hangs forever because it can't finish downloading, you'll understand why this section exists.
Start OTA checks with a delay after boot. Don't check on startup. If the firmware you just flashed is bad, you'll immediately try to update back to the broken version, and your device is toast. Instead, use a delay of at least a few minutes:
#define OTA_INITIAL_DELAY_MS (5 * 60 * 1000) // 5 minutes void app_main() { // ... initialize, WiFi, etc ... // Don't check OTA immediately; let the device stabilize vTaskDelay(OTA_INITIAL_DELAY_MS / portTICK_PERIOD_MS); xTaskCreate(ota_task, "ota_task", 4096, NULL, 5, NULL); }
This gives you a window to manually reflash if the new firmware is broken. On OTA success, the bootloader sets the new partition as active on next boot, so if something is wrong you have a chance to stop it.
Space out OTA checks to hours, not minutes. Polling every 5 minutes wastes power and network bandwidth for no benefit. Once every 4-6 hours is reasonable for most devices. If you're on solar or battery, make it longer:
#define OTA_CHECK_INTERVAL_MS (4 * 60 * 60 * 1000) // 4 hours void ota_task(void *pvParameters) { TickType_t last_check = xTaskGetTickCount(); while (1) { TickType_t now = xTaskGetTickCount(); if ((now - last_check) > pdMS_TO_TICKS(OTA_CHECK_INTERVAL_MS)) { perform_ota_update(); last_check = now; } vTaskDelay(pdMS_TO_TICKS(60000)); // Check the clock once per minute } }
Download in chunks and call esp_task_wdt_reset() per chunk. This is the hard-won lesson. If you use the ESP-IDF's esp_https_ota_perform() in blocking mode (which is easy), it will sit in one function call for the entire download. On a slow connection, that's minutes. The watchdog timer gets angry and reboots your device mid-update.
Instead, use the chunked OTA API:
#include "esp_https_ota.h" esp_err_t ota_with_watchdog_reset(const char *url) { esp_https_ota_config_t config = { .http_config = &http_config, }; esp_https_ota_handle_t https_ota_handle = NULL; esp_err_t err = esp_https_ota_begin(&config, &https_ota_handle); if (err != ESP_OK) { return err; } while (1) { err = esp_https_ota_perform(https_ota_handle); if (err == ESP_ERR_HTTPS_OTA_IN_PROGRESS) { // Reset watchdog on each chunk esp_task_wdt_reset(); continue; } else { break; } } if (esp_https_ota_is_complete_data_received(https_ota_handle)) { err = esp_https_ota_finish(https_ota_handle); } else { esp_https_ota_abort(https_ota_handle); } return err; }
Yes, you'll need to tune the watchdog timeout itself, but resetting per chunk is the key. Without it, slow downloads fail mysteriously.
Defensive programming: tracking reset reasons and device state
If a device reboots out in the field, you need to know why. Logging doesn't help if you can't reach the serial console. Build diagnostics into the firmware itself.
Capture the reset reason on every boot:
#include "esp_system.h" #include "nvs_flash.h" typedef struct { uint32_t reset_count; uint32_t last_reset_reason; uint32_t last_ota_attempt_ms; uint32_t last_data_post_ms; uint32_t wifi_fail_count; } device_state_t; void track_reset_reason() { esp_reset_reason_t reason = esp_reset_reason(); device_state_t state = {}; nvs_handle_t nvs_handle; nvs_open("device", NVS_READWRITE, &nvs_handle); nvs_get_blob(nvs_handle, "state", &state, sizeof(device_state_t)); state.reset_count++; state.last_reset_reason = (uint32_t)reason; nvs_set_blob(nvs_handle, "state", &state, sizeof(device_state_t)); nvs_commit(nvs_handle); nvs_close(nvs_handle); ESP_LOGI(TAG, "Boot #%lu, last reset reason: %d", state.reset_count, state.last_reset_reason); }
Implement a safe-mode escape hatch. If your device has rebooted 10 times in the last 5 minutes, something is very wrong. Entering a safe mode that doesn't run the main logic prevents a reboot loop from destroying data or spinning through CPU cycles:
#define UNSAFE_REBOOT_THRESHOLD 10 #define REBOOT_WINDOW_MS (5 * 60 * 1000) void check_for_reboot_loop(device_state_t *state) { uint32_t time_since_reset = esp_timer_get_time() / 1000; // If we've rebooted many times in a short window, enter safe mode if (state->reset_count > UNSAFE_REBOOT_THRESHOLD && time_since_reset < REBOOT_WINDOW_MS) { ESP_LOGW(TAG, "Too many reboots! Entering safe mode."); // Don't run main logic; just sit in a loop and wait for OTA while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); } } }
Expose device state via HTTP. Add a simple debug endpoint that returns JSON with diagnostic info. You can query it without serial access:
esp_err_t debug_handler(httpd_req_t *req) { device_state_t state = {}; nvs_handle_t nvs_handle; nvs_open("device", NVS_READONLY, &nvs_handle); nvs_get_blob(nvs_handle, "state", &state, sizeof(device_state_t)); nvs_close(nvs_handle); cJSON *root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "reset_count", state.reset_count); cJSON_AddNumberToObject(root, "last_reset_reason", state.last_reset_reason); cJSON_AddNumberToObject(root, "wifi_fail_count", state.wifi_fail_count); cJSON_AddNumberToObject(root, "uptime_ms", esp_timer_get_time() / 1000); char *json_str = cJSON_Print(root); httpd_resp_set_type(req, "application/json"); httpd_resp_sendstr(req, json_str); cJSON_Delete(root); free(json_str); return ESP_OK; }
Then register it during HTTP server setup. Now you can SSH into a jump host, curl your device, and get a full picture without touching a serial cable.
NVS (NVRAM) diagnostics: what to track and why
Not every value is worth persisting, but the right ones save you hours of debugging. Here's what I track:
- Reset count: how many times has this device rebooted?
- Last OTA attempt timestamp: did it try to update recently? (Stuck devices often have an old timestamp here.)
- Last successful data-post timestamp: when was the last time this device actually transmitted data?
- WiFi connect failure count: is WiFi flaky?
typedef struct { uint32_t reset_count; uint32_t last_reset_reason; uint32_t last_ota_attempt_ms; // Milliseconds since epoch uint32_t last_data_post_ms; // Milliseconds since epoch uint32_t wifi_fail_count; } device_state_t; void update_ota_timestamp() { device_state_t state = {}; nvs_handle_t nvs_handle; nvs_open("device", NVS_READWRITE, &nvs_handle); nvs_get_blob(nvs_handle, "state", &state, sizeof(device_state_t)); state.last_ota_attempt_ms = (uint32_t)(esp_timer_get_time() / 1000); nvs_set_blob(nvs_handle, "state", &state, sizeof(device_state_t)); nvs_commit(nvs_handle); nvs_close(nvs_handle); } void update_data_post_timestamp() { device_state_t state = {}; nvs_handle_t nvs_handle; nvs_open("device", NVS_READWRITE, &nvs_handle); nvs_get_blob(nvs_handle, "state", &state, sizeof(device_state_t)); state.last_data_post_ms = (uint32_t)(esp_timer_get_time() / 1000); nvs_set_blob(nvs_handle, "state", &state, sizeof(device_state_t)); nvs_commit(nvs_handle); nvs_close(nvs_handle); }
Now your /debug endpoint tells you:
- "This device last sent data 3 hours ago" → likely a WiFi issue
- "Reset reason is POWERON, reset count is 50, OTA timestamp is from this morning" → firmware is stable but something else keeps power-cycling the board
- "WiFi fail count is 200 but uptime is only 2 hours" → authentication problem
Power savings for solar+battery devices
If your ESP32 is sitting outside powered by a solar panel and a battery, every milliamp-hour matters. Deep sleep is your friend, but there are sneaky current draws you need to minimize.
Use deep sleep liberally. If your device only needs to transmit once per hour, sleep for 59 minutes and wake for 1 minute:
#define SLEEP_TIME_US (59 * 60 * 1000000) // 59 minutes void app_main() { // ... initialize, WiFi, transmit data, update OTA ... // Deep sleep consumes ~10 µA (vs ~80 mA active) esp_deep_sleep(SLEEP_TIME_US); }
Minimize WiFi active time. WiFi startup takes a second or two and draws ~200 mA. Shave every millisecond:
- Use a static IP instead of DHCP. DHCP adds 100-500 ms to startup, depending on your router.
- Use WPA2_PSK, not mixed WPA2/WPA3. WPA2 negotiation is faster.
- Connect to the closest/strongest AP to minimize retries.
wifi_config_t wifi_config = { .sta = { .ssid = (uint8_t *)CONFIG_WIFI_SSID, .password = (uint8_t *)CONFIG_WIFI_PASSWORD, .threshold.authmode = WIFI_AUTH_WPA2_PSK, .pmf_cfg = { .capable = true, .required = false, }, }, }; // Use static IP tcpip_adapter_ip_info_t ip_info = { .ip = { .addr = ipaddr_addr("192.168.0.100") }, .netmask = { .addr = ipaddr_addr("255.255.255.0") }, .gw = { .addr = ipaddr_addr("192.168.0.1") }, }; tcpip_adapter_set_ip_info(TCPIP_ADAPTER_STA, &ip_info);
(Substitute your actual IP range, of course.)
Consider ESP-NOW for ultra-low-power sensors. If you have a receiver (another ESP32, always-on gateway, etc.) nearby, ESP-NOW uses 150-200 ms of active time versus 2+ seconds for WiFi. The tradeoff is that you need infrastructure:
void send_via_espnow(uint8_t *payload, size_t len) { // ESP-NOW transmit: ~100-150 ms active time, much less power // Requires a paired receiver esp_now_send(broadcast_mac, payload, len); // Deep sleep immediately after esp_deep_sleep(SLEEP_TIME_US); }
Budget your power envelope. If you have a 500 mAh battery and 500 mW solar charging on a cloudy day, you can sustain about 5 mAh per hour (rough math). If each transmit-and-OTA-check cycle takes 500 mA for 10 seconds, that's 1.4 mAh. Do that once per hour and you're breaking even or losing charge. Do it twice per hour on a cloudy day and your battery drains. Pick your sleep interval based on what your solar panel can actually charge:
#define SLEEP_INTERVAL_MS (2 * 60 * 60 * 1000) // 2 hours for marginal solar // vs. #define SLEEP_INTERVAL_MS (30 * 60 * 1000) // 30 min for good sun/large battery
Test in the actual sun conditions you'll deploy in. A device that runs fine in your garage with good WiFi will fail miserably in the field.
These patterns have saved me from devices that reboot in loops, OTA updates that hang forever, and batteries that drain in a week instead of a month. They're not ESP-IDF-specific — most of these apply to any embedded system — but they're the things I reach for in every firmware project now. The defensive diagnostics pay for themselves the first time you need to debug a device you can't physically reach.
Comments
Comments powered by Disqus