Embedded & IoT No. 459

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.

  • nameesp32-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.

~/.claude/skills/esp32-firmware/SKILL.md

--- name: esp32-firmware 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,… license: MIT ---

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 before idf.py erase-flash, which wipes NVS (Wi-Fi credentials, calibration, device identity).

Framework choice

SituationUse
Production product, OTA, security, power tuningESP-IDF directly
Prototype or reusing Arduino librariesArduino-ESP32 core 3.x (built on IDF 5.x)
Need bothArduino as an ESP-IDF component
Rustesp-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 (and sdkconfig.defaults.<target> for per-chip overrides) plus dependencies.lock. Treat sdkconfig as generated.
  • In CI, use the espressif/idf:v<version> Docker image with the same version.
  • idf.py monitor decodes 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_provisioning component) or a config step, store in NVS.
  • nvs_flash_init() must run before esp_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 schema key 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.py and 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_main runs again. Use esp_sleep_get_wakeup_cause() to branch, and RTC_DATA_ATTR variables 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

SymptomLikely cause
Brownout detector was triggeredSupply sag on Wi-Fi TX; add capacitance, better regulator
Boot loop with invalid headerWrong flash mode/size in menuconfig, or strapping pin pulled wrong
Guru Meditation Error: LoadProhibitedNull/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 triggeredA task spins without yielding; add vTaskDelay or blocking waits
App does not fitPartition too small; idf.py size-components, then resize per reference
Settings vanish after flashFlashing with erase, or NVS partition offset moved

ESP-IDF tasks are FreeRTOS tasks; for priorities, queues and ISR rules use freertos-tasks.

Verification

  1. idf.py build produces no warnings in your own components (add target_compile_options(${COMPONENT_LIB} PRIVATE -Werror) to them); idf.py size shows each app slot ≥ 20% free.
  2. Log uxTaskGetStackHighWaterMark(NULL) for each task after a full feature run; keep ≥ 512 bytes headroom.
  3. 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.
  4. Reboot 20 times; settings persist and boot time stays stable.
  5. 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 93 lines · 4.3 KB
# 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.

esc