ESP32 Firmware skill for Claude Code
Wi-Fi that reconnects, partitions that still fit. Claude pins the chip target and ESP-IDF version, keeps configuration in sdkconfig.defaults, writes Wi-Fi code that logs disconnect reasons and retries with backoff, sizes partition tables with OTA headroom, and versions NVS settings. It flags strapping pins and brownout causes before blaming code, and refuses to burn eFuses for secure boot or flash encryption without explicit sign-off.
- name
esp32-firmware - size218 lines · ≈2,600 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds ESP32-family firmware with ESP-IDF (v5.x/v6.x) or the Arduino-ESP32 core — Wi-Fi station with reconnect backoff, BLE via NimBLE, custom partition tables, NVS storage, deep sleep and wake sources, menuconfig/sdkconfig management, and idf.py build-flash-monitor loops. Use when writing or debugging ESP32, ESP32-S3, C3, C6 or H2 firmware, when Wi-Fi drops or never connects, when the app outgrows its partition, when NVS loses settings, or when planning secure boot and flash encryption. Try asking
- My ESP32-S3 drops Wi-Fi overnight and never reconnects — fix the event handling
- Design a partition table for an 8 MB ESP32 with OTA, coredump, and LittleFS
- The build says the app does not fit in the partition. What are my options?
Use it when
- Writing or debugging ESP-IDF or Arduino-ESP32 firmware
- Wi-Fi or BLE connects unreliably or causes brownouts
- Designing partition tables, NVS storage, or factory data
- Adding deep sleep or planning secure boot and flash encryption
Skip it when
- Designing the OTA rollout, signing, and rollback policy — use firmware-ota
- Plain Arduino sketches on AVR or RP2040 boards — use arduino-projects
How to check the result
idf.py size shows at least 20% free in each app slot, stack high-water marks keep headroom, and the device reconnects after repeated AP power cycles with flat heap over hours.
The common mistake it prevents
Running nvs_flash_erase on every init failure in production, silently wiping Wi-Fi credentials and device identity after a firmware update changes the NVS format.
Done well means: the target and IDF version are pinned, sdkconfig.defaults (not a
hand-edited sdkconfig) holds the configuration, Wi-Fi reconnects with backoff forever,
the partition table leaves at least 20% free in each app slot, NVS writes are minimal
and versioned, and nothing irreversible (eFuses) happens without explicit approval.
Ground rules
- ESP32 GPIOs are 3.3 V and not 5 V tolerant. Keep source/sink around 20 mA per pin (the abs. max is higher; do not design to it). Level-shift 5 V signals.
- Wi-Fi TX peaks draw 300–500 mA. Brownouts during connect mean the supply or the LDO cannot deliver; add bulk capacitance (≥ 100 µF near the module) before touching code.
- Strapping pins (ESP32: GPIO0, 2, 5, 12, 15; S3: 0, 3, 45, 46; C3: 2, 8, 9) decide boot mode and flash voltage. Do not hang pull-ups/pull-downs or LEDs on them without checking the datasheet. GPIO12 pulled high at boot on classic ESP32 selects 1.8 V flash and causes boot loops on 3.3 V-flash modules.
- GPIO6–11 on classic ESP32 (and the in-package flash/PSRAM pins on S3) are wired to flash. Never use them.
- Irreversible operations require explicit user confirmation, stated as such:
burning eFuses (
espefuse.py burn_efuse,burn_key), enabling secure boot or flash encryption in Release mode, disabling JTAG/ROM download. A wrong key or mode bricks the chip permanently. Also confirm beforeidf.py erase-flash, which wipes NVS (Wi-Fi credentials, calibration, device identity).
Framework choice
| Situation | Use |
|---|---|
| Production product, OTA, security, power tuning | ESP-IDF directly |
| Prototype or reusing Arduino libraries | Arduino-ESP32 core 3.x (built on IDF 5.x) |
| Need both | Arduino as an ESP-IDF component |
| Rust | esp-hal / esp-idf-svc — see embedded-rust |
ESP-IDF v6.0 (2026) is the current major line and v5.5 remains maintained. v6 switched
the C library to Picolibc, ships an installation manager, and removes APIs deprecated
during v5 — old “legacy” drivers (e.g. driver/i2c.h, legacy ADC/timer APIs) should be
replaced with driver/i2c_master.h, esp_adc/adc_oneshot.h, driver/gptimer.h.
Check the migration guide for the exact version before upgrading.
Workflow
- [ ] 1. Pin chip target and IDF version (record in README and CI image)
- [ ] 2. Create project; commit sdkconfig.defaults, not just sdkconfig
- [ ] 3. Design the partition table (OTA? coredump? storage?)
- [ ] 4. Bring up: logging, Wi-Fi/BLE, peripherals one at a time
- [ ] 5. Persist settings in NVS with a schema version
- [ ] 6. Add power management / deep sleep if battery powered
- [ ] 7. Size check, stack high-water marks, soak test with Wi-Fi drops
- [ ] 8. Security plan (secure boot v2, flash encryption) — confirm before burning eFuses
Project loop
. $IDF_PATH/export.sh # or the installation manager's activation script
idf.py create-project myapp && cd myapp
idf.py set-target esp32s3 # resets sdkconfig for the new target
idf.py menuconfig # then save minimal config:
idf.py save-defconfig # writes sdkconfig.defaults
idf.py add-dependency "espressif/led_strip^3" # component manager -> idf_component.yml
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor # Ctrl+] exits monitor
idf.py size-components # who uses flash/RAM
- Commit
sdkconfig.defaults(andsdkconfig.defaults.<target>for per-chip overrides) plusdependencies.lock. Treatsdkconfigas generated. - In CI, use the
espressif/idf:v<version>Docker image with the same version. idf.py monitordecodes backtrace addresses automatically when the ELF matches the flashed binary; keep the ELF for every released build.
Wi-Fi station with reconnect
static int s_retry;
static void on_wifi(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) {
wifi_event_sta_disconnected_t *d = data;
int delay_ms = MIN(1000 << MIN(s_retry, 5), 30000); // 1 s .. 30 s
ESP_LOGW(TAG, "disconnected reason=%d, retry in %d ms", d->reason, delay_ms);
s_retry++;
// Do not block the event task: signal a timer/task to call esp_wifi_connect()
xTimerChangePeriod(s_reconnect_timer, pdMS_TO_TICKS(delay_ms), 0);
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
s_retry = 0;
xEventGroupSetBits(s_net_events, NET_UP_BIT);
}
}
void wifi_init_sta(void) {
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_wifi, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi, NULL));
wifi_config_t wc = { .sta = { .threshold.authmode = WIFI_AUTH_WPA2_PSK } };
// copy SSID/password from NVS into wc.sta.ssid / 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());
}
- Disconnect reason codes (
wifi_err_reason_t) tell you why: 201 no AP found, 15/204 handshake timeout (wrong password), 2/8 auth expired / leaving. Log them. - Never hardcode credentials in source. Provision via BLE/SoftAP (the
network_provisioningcomponent) or a config step, store in NVS. nvs_flash_init()must run beforeesp_wifi_init()(Wi-Fi stores calibration in NVS).- Wi-Fi + BLE coexistence works but costs RAM and throughput; enable only what ships.
BLE
- BLE-only: use NimBLE (
CONFIG_BT_NIMBLE_ENABLED); it is far smaller than Bluedroid. - Classic Bluetooth (A2DP, SPP) needs Bluedroid and only exists on the original ESP32.
- C3/C6/S3/H2 support BLE 5; the classic ESP32 is BLE 4.2.
- Release classic BT memory when unused:
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT).
NVS
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
// Partition is full or was written by a newer NVS format. Erasing loses all keys —
// acceptable on first boot of a dev board; in production, log and decide deliberately.
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
nvs_handle_t h;
ESP_ERROR_CHECK(nvs_open("cfg", NVS_READWRITE, &h));
uint8_t schema = 0;
nvs_get_u8(h, "schema", &schema); // migrate when schema < CURRENT
nvs_set_u32(h, "interval_s", 300);
ESP_ERROR_CHECK(nvs_commit(h));
nvs_close(h);
- Keys and namespaces max 15 characters. Store a
schemakey and migrate on boot. - Write only on change. NVS wear-levels, but a value rewritten every second still burns flash and slows boots.
- For per-device factory data (serial, certs), use a separate NVS partition generated
with
nvs_partition_gen.pyand keep it out of OTA images.
Partitions
Default tables are fine only for small apps without OTA. For OTA, coredump, or a
filesystem, write partitions.csv and set CONFIG_PARTITION_TABLE_CUSTOM. Read
references/partition-tables.md when designing or
resizing a table, or when the build says the app does not fit.
Deep sleep
esp_sleep_enable_timer_wakeup(300ULL * 1000000); // 5 min, microseconds
esp_sleep_enable_ext1_wakeup_io(1ULL << GPIO_NUM_4, ESP_EXT1_WAKEUP_ANY_LOW); // RTC GPIO
esp_deep_sleep_start(); // never returns; wake = reboot
- Wake from deep sleep is a reset:
app_mainruns again. Useesp_sleep_get_wakeup_cause()to branch, andRTC_DATA_ATTRvariables for small state that survives sleep (lost on power loss). - Only RTC-capable GPIOs can wake from deep sleep; the available APIs differ per chip (ext0 exists only on chips with RTC IO; C3/C6 use GPIO wakeup). Check the chip’s sleep-modes page.
- Floating pins and onboard USB-UART bridges, LEDs, and LDOs dominate sleep current on dev boards. Measure on the real board; see low-power-firmware.
Debugging
| Symptom | Likely cause |
|---|---|
Brownout detector was triggered | Supply sag on Wi-Fi TX; add capacitance, better regulator |
Boot loop with invalid header | Wrong flash mode/size in menuconfig, or strapping pin pulled wrong |
Guru Meditation Error: LoadProhibited | Null/dangling pointer; read the decoded backtrace |
Stack canary watchpoint triggered (task) | That task’s stack is too small; raise it, check uxTaskGetStackHighWaterMark |
Task watchdog got triggered | A task spins without yielding; add vTaskDelay or blocking waits |
| App does not fit | Partition too small; idf.py size-components, then resize per reference |
| Settings vanish after flash | Flashing with erase, or NVS partition offset moved |
ESP-IDF tasks are FreeRTOS tasks; for priorities, queues and ISR rules use freertos-tasks.
Verification
idf.py buildproduces no warnings in your own components (addtarget_compile_options(${COMPONENT_LIB} PRIVATE -Werror)to them);idf.py sizeshows each app slot ≥ 20% free.- Log
uxTaskGetStackHighWaterMark(NULL)for each task after a full feature run; keep ≥ 512 bytes headroom. - Wi-Fi soak: power-cycle the AP repeatedly; the device reconnects every time without
reboot, and
esp_get_free_heap_size()stays flat over hours. - Reboot 20 times; settings persist and boot time stays stable.
- For sleep builds, measure current with a power profiler on the target hardware.
Output contract
**Target / IDF:** esp32s3, ESP-IDF v6.0.x (Docker espressif/idf:v6.0.x)
**Config:** sdkconfig.defaults (+ sdkconfig.defaults.esp32s3)
**Partitions:** nvs 24K, otadata 8K, phy 4K, ota_0 1.75M, ota_1 1.75M, coredump 64K
**Connectivity:** Wi-Fi STA, backoff 1–30 s, provisioning via BLE (NimBLE)
**Persistence:** NVS namespace "cfg", schema v2
**Security:** secure boot / flash encryption NOT enabled (requires explicit sign-off)
**Measured:** heap flat over 8 h, min stack headroom 900 B, sleep 14 µA on custom board Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/partition-tables.md
# ESP-IDF partition tables
Read this when designing a custom table, adding OTA, or when the build reports that
the app image does not fit.
## Format
`partitions.csv`, one partition per line:
```
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, , 0x6000,
otadata, data, ota, , 0x2000,
phy_init, data, phy, , 0x1000,
ota_0, app, ota_0, , 0x1C0000,
ota_1, app, ota_1, , 0x1C0000,
coredump, data, coredump, , 0x10000,
storage, data, littlefs, , 0x60000,
```
- Leave Offset blank to auto-pack. The partition table itself sits at
`CONFIG_PARTITION_TABLE_OFFSET` (default 0x8000) and the first partition starts after
it (0x9000 by default).
- App partitions must be aligned to 0x10000 (64 KB); the generator pads automatically
when offsets are blank. Data partitions align to 0x1000.
- Sizes accept `K`/`M` suffixes (`24K`, `1792K`, `1M`).
- Enable with `CONFIG_PARTITION_TABLE_CUSTOM=y` and
`CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"`; set
`CONFIG_ESPTOOLPY_FLASHSIZE` to the real flash size or the build will reject a table
that is larger than the configured flash.
- If secure boot or a larger bootloader pushes past 0x8000, move the table offset
(e.g. 0x10000) — but only before devices ship; moving it on fielded devices requires a
reflash over serial.
## Layouts by flash size
| Flash | Layout | App slot size |
|---|---|---|
| 4 MB, no OTA | nvs 24K, phy 4K, factory 3M, storage rest | ~3 MB |
| 4 MB, OTA | nvs 24K, otadata 8K, phy 4K, ota_0 1.75M (0x1C0000), ota_1 1.75M | 1.75 MB |
| 4 MB, OTA + coredump + small FS | as above with ota slots 1.5M, coredump 64K, littlefs ~900K | 1.5 MB |
| 8 MB, OTA | nvs 24K, otadata 8K, phy 4K, ota_0 3M, ota_1 3M, coredump 64K, storage rest | 3 MB |
| 16 MB, OTA + factory | nvs, otadata, phy, factory 2M (recovery), ota_0 4M, ota_1 4M, storage rest | 4 MB |
- A `factory` app plus OTA slots gives a recovery image, but the bootloader only falls
back to it when no valid OTA slot is selectable. Most products use just ota_0/ota_1
with rollback enabled (see firmware-ota).
- Size each OTA slot at ≥ 1.25× the current image so feature growth does not force a
repartition. Repartitioning fielded devices over OTA is risky and usually impossible
without a custom migration step.
## NVS sizing
- Minimum practical: 0x3000 (3 pages). Default 0x6000 (24 KB) suits Wi-Fi credentials,
PHY calibration and a few dozen keys.
- NVS needs one free page for garbage collection; a nearly full partition returns
`ESP_ERR_NVS_NOT_ENOUGH_SPACE` or triggers the `NO_FREE_PAGES` erase path at init.
- Blobs (certificates, large configs) eat pages quickly; give 0x10000+ if storing them,
or use a separate partition.
- `nvs_get_stats()` reports used/free entries — log it during development.
- Factory/per-device data: separate partition (e.g. `fctry, data, nvs, , 0x6000`),
generated by `nvs_partition_gen.py` and flashed once at manufacturing. OTA never
touches it.
- NVS encryption needs an `nvs_keys` partition (flash-encryption scheme) or the
HMAC-based scheme on chips that support it; decide before shipping.
## Coredump
- `coredump, data, coredump, , 64K` with `CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y`.
- Read with `idf.py coredump-info` (or `coredump-debug`) against the matching ELF.
- Upload coredumps over your telemetry path on next boot, then erase the partition.
## Filesystems
| Need | Use |
|---|---|
| Small files, power-loss safety | LittleFS (`joltwallet/littlefs` component) |
| Read-mostly assets, PC interop | FAT with wear levelling (`data, fat`) |
| Legacy | SPIFFS (deprecated in practice; no directories, slow when full) |
## Tools
```bash
idf.py partition-table # build and print the table
python $IDF_PATH/components/partition_table/gen_esp32part.py build/partition_table/partition-table.bin
parttool.py --port /dev/ttyUSB0 read_partition --partition-name nvs --output nvs.bin
parttool.py --port /dev/ttyUSB0 erase_partition --partition-name storage # confirm with user first
idf.py size # image size vs. smallest app partition
```
`erase_partition` and `idf.py erase-flash` destroy stored data (credentials, device
identity). Confirm with the user before running them on anything other than a
throwaway dev board.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.