ESP32 Firmware skill for Codex
Reconnect, sleep and update without a cable. Codex treats an ESP32 project as a device that must recover alone: Wi-Fi is event-driven with reconnect, deep sleep is measured on the battery lead, partitions include two OTA slots with rollback, and every panic is decoded against an archived ELF. It also knows which strapping and flash pins to keep away from.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- esp32-firmware Installs to ~/.agents/skills/esp32-firmware/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install esp32-firmware The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project esp32-firmware Installs to .agents/skills/esp32-firmware/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/esp32-firmware.zipunzip -o esp32-firmware.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) esp32-firmware PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $esp32-firmware, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds ESP32 firmware with ESP-IDF 5.x (or the Arduino-ESP32 3.x core) - Wi-Fi station with reconnect, NVS, deep sleep with timer and GPIO wake, custom partition tables, HTTPS OTA with rollback, and decoding panics and watchdog resets. Use when an ESP32, ESP32-S3, C3 or C6 project will not connect to Wi-Fi, drains its battery, boot-loops with Guru Meditation, needs OTA, or when choosing pins, partitions and sdkconfig options.
Use it when
- An ESP32 drops off Wi-Fi or boot-loops with Guru Meditation errors
- A battery-powered ESP32 node drains faster than expected
- You need a partition table and OTA updates with rollback
- Choosing GPIO pins, ADC channels or sdkconfig options for a new board
Use something else when
- You need fleet-wide signing, staged rollouts or anti-rollback design: use firmware-ota
- You are running MicroPython on the ESP32: use micropython
How to check the result
Power-cycle the access point and confirm reconnection, measure sleep current on the battery lead, and push a deliberately broken OTA image to prove rollback.
The mistake it prevents
Pulling GPIO12 high at boot on a classic ESP32 selects 1.8 V flash and the module will not boot; strapping pins need checking before any PCB layout.
Done well means: the device reconnects on its own after any Wi-Fi or power event, sleeps at the current the datasheet promises, can be updated over the air and roll back, and every crash in the field produces a decodable backtrace.
Inspect first
- Read
AGENTS.md, then find the framework:CMakeLists.txtwithinclude($ENV{IDF_PATH}/tools/cmake/project.cmake)is ESP-IDF;.inoorplatformio.iniwithframework = arduinois the Arduino core. - Record the chip and IDF version:
idf.py --version,CONFIG_IDF_TARGETinsdkconfig. ESP32, S2, S3, C3, C6 and H2 differ in cores, wake sources, ADC and radio (H2 has no Wi-Fi). - Read
sdkconfig.defaults(the committed source of truth) andpartitions.csv.sdkconfigitself is generated; changes belong insdkconfig.defaults.
idf.py set-target esp32s3 # wipes build config; do once per target
idf.py menuconfig # then copy the changed keys into sdkconfig.defaults
idf.py build
idf.py size-components # who uses flash and DRAM
idf.py -p /dev/ttyUSB0 flash monitor # the user runs this; Codex rarely has the port
Installing ESP-IDF or components through the component manager
(idf.py add-dependency "espressif/led_strip^3.0.0") needs network access.
Pin selection
| Avoid on classic ESP32 | Why |
|---|---|
| GPIO 6-11 | Wired to the SPI flash |
| GPIO 0, 2, 5, 12, 15 | Strapping pins; GPIO12 high at boot selects 1.8 V flash and bricks boot on most modules |
| GPIO 34-39 for outputs | Input only, no internal pull-ups |
| ADC2 pins with Wi-Fi on | ADC2 is unavailable while Wi-Fi runs; use ADC1 |
S3 and C3 have different strapping pins and USB-JTAG on GPIO 19/20 (S3) or 18/19 (C3); check the chip’s datasheet table rather than reusing ESP32 pin maps.
Wi-Fi station that survives the real world
static EventGroupHandle_t s_net;
#define NET_UP BIT0
static void on_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
xEventGroupClearBits(s_net, NET_UP);
esp_wifi_connect(); // add backoff for battery devices
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
xEventGroupSetBits(s_net, NET_UP);
}
}
void wifi_start(void) {
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
s_net = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, on_event, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_event, NULL));
wifi_config_t wc = { .sta = { .threshold.authmode = WIFI_AUTH_WPA2_PSK } };
strlcpy((char *)wc.sta.ssid, CONFIG_APP_WIFI_SSID, sizeof wc.sta.ssid);
strlcpy((char *)wc.sta.password, CONFIG_APP_WIFI_PASS, sizeof wc.sta.password);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc));
ESP_ERROR_CHECK(esp_wifi_start());
}
- Code that needs the network waits on
NET_UPwith a timeout; it never assumes it. - Credentials come from Kconfig for development and NVS (provisioning) for products;
never commit real SSIDs and passwords in
sdkconfig.defaults. - Arduino core equivalent:
WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true);plus aWiFi.onEventhandler; still checkWiFi.status()before every network call.
Deep sleep
RTC_DATA_ATTR static uint32_t boot_count; // survives deep sleep, not power loss
void sleep_now(uint64_t seconds) {
esp_sleep_enable_timer_wakeup(seconds * 1000000ULL);
esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 0); // ext0: ESP32/S2/S3 only, RTC GPIO
esp_wifi_stop();
esp_deep_sleep_start(); // does not return; next run is a reset
}
- Read
esp_sleep_get_wakeup_cause()at boot to branch between timer, button and cold boot. - Board current, not chip current, decides battery life: USB-UART bridges, LDO quiescent current and power LEDs often draw 1-10 mA. Measure with a current meter on the battery lead, not the USB port.
- Hold output states through sleep with
gpio_hold_en()plusgpio_deep_sleep_hold_en(), or pins float and external parts wake up. - C3 and C6 wake from GPIO through
esp_deep_sleep_enable_gpio_wakeup(), not ext0.
Partitions and OTA
A minimal two-slot table for 4 MB flash:
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x4000
otadata, data, ota, 0xd000, 0x2000
phy_init, data, phy, 0xf000, 0x1000
ota_0, app, ota_0, 0x10000, 0x1E0000
ota_1, app, ota_1, , 0x1E0000
Set CONFIG_PARTITION_TABLE_CUSTOM=y, CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
and CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y in sdkconfig.defaults.
esp_http_client_config_t http = { .url = url, .cert_pem = server_ca_pem, .timeout_ms = 15000 };
esp_https_ota_config_t ota = { .http_config = &http };
if (esp_https_ota(&ota) == ESP_OK) esp_restart();
// In the new image, after self-tests pass (network up, sensors read):
esp_ota_mark_app_valid_cancel_rollback();
Changing the partition table on deployed devices requires a serial reflash; get it right
before shipping. Signing, staged rollout and anti-rollback live in firmware-ota.
Crashes and resets
| Output | Meaning | Action |
|---|---|---|
Guru Meditation Error: Core 0 panic'ed (LoadProhibited) | Null or wild pointer read | Decode the backtrace (below) |
***ERROR*** A stack overflow in task X | Task stack too small | Raise the stack in xTaskCreate (bytes on ESP-IDF) |
Task watchdog got triggered | A task never yields | Add vTaskDelay, move work off busy loops |
Brownout detector was triggered | Supply sags on Wi-Fi TX bursts | Bulk capacitor, better regulator; do not disable the detector |
rst:0x10 (RTCWDT_RTC_RESET) boot loop | Bad strapping pin or flash settings | Check GPIO 0/2/12/15 wiring |
idf.py monitor decodes backtraces automatically when given the matching ELF. For field
logs, use xtensa-esp32-elf-addr2line -pfiaC -e build/app.elf 0x400d1234 ... (RISC-V chips
use riscv32-esp-elf-addr2line). Keep every released ELF; without it a backtrace is noise.
Enable CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH for devices you cannot watch over serial.
Gotchas
ESP_ERROR_CHECKaborts on failure; in production paths handle the error instead.- Do not call blocking or logging functions from ISRs; defer to a task (
freertos). idf.py set-targetdeletes the existingsdkconfig; anything not insdkconfig.defaultsis lost.- The Arduino core’s
delay()yields to FreeRTOS; a tightwhileloop without it trips the watchdog on core 0. - NVS keys are at most 15 characters.
Verification
-
idf.py buildclean;idf.py sizewithin budget with OTA slot headroom (aim for 20 %) - Power-cycle the router during operation: device reconnects without reboot
- Deep sleep current measured on the battery lead and written down
- OTA to a deliberately broken image rolls back to the previous slot
- A forced panic produces a decodable backtrace with the archived ELF
Report
**Target:** ESP32-S3, ESP-IDF v5.3, 8 MB flash, custom 2-slot OTA table
**Change:** event-driven Wi-Fi reconnect with backoff; deep sleep 300 s with ext0 button wake
**Measured:** 14 uA in sleep on the battery lead (was 4.1 mA: power LED, now removed)
**OTA:** https OTA with rollback; broken-image test rolled back correctly
**Open:** provisioning still uses Kconfig credentials; NVS-based provisioning not done What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "ESP32 Firmware"
short_description: "Reconnect, sleep and update without a cable"
default_prompt: "$esp32-firmware My ESP32 never reconnects after the router reboots, fix the Wi-Fi code" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.