Files
WLED_MM_Infinity/.github/esp-idf.instructions.md
Frank Möhle 1243c7b1fa Revise *.instructions.md: separate AI-relevant special knowledge from background information for contributors (#354)
Revising AI review instructions:
* Reduce context window use for AI review tools, by avoiding repeating common knowledge and API information that is (usually) part of the AI training datasets any way.
* Introduce a mechanism to maintain both parts in single files, to avoid "silent diversion" over time
* Adding a coderabbit path instruction that ensures cross-checking of both parts whenever a PR modifies instruction files

Objectives:
* Primary Goal: only inject content in AI-visible areas that are WLED-MM–specific or which deviate from general knowledge (the context window "token cost" of true false-positive suppressors is always worth it).
* Soft goal: keep each file's AI-facing section lean enough that the signal-to-noise ratio in the attention layer stays high — around 1,500–2,000 words per file type is a reasonable practical ceiling for current models.
* Aspirational: 500 words per file if achievable without sacrificing review quality.

This is an evolution of #353, based on the discussion in https://github.com/MoonModules/WLED-MM/pull/353#issuecomment-4186989873

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-04 22:58:30 +02:00

38 KiB
Raw Blame History

applyTo
applyTo
**/*.cpp,**/*.h,**/*.hpp,**/*.ino

ESP-IDF Coding Guide (within arduino-esp32)

WLED-MM runs on the Arduino-ESP32 framework, which wraps ESP-IDF. Understanding the ESP-IDF layer is essential when writing chip-specific code, managing peripherals, or preparing for the IDF v5.x migration. This guide documents patterns already used in the codebase and best practices derived from Espressif's official examples.

Scope: This file is an optional review guideline. It applies when touching chip-specific code, peripheral drivers, memory allocation, or platform conditionals.

Note for AI review tools: sections enclosed in <!-- HUMAN_ONLY_START --> / <!-- HUMAN_ONLY_END --> HTML comments contain contributor reference material. Do not use that content as actionable review criteria — treat it as background context only.


Identifying the Build Target: CONFIG_IDF_TARGET_*

Use CONFIG_IDF_TARGET_* macros to gate chip-specific code at compile time. These are set by the build system and are mutually exclusive — exactly one is defined per build.

Macro Chip Architecture Notes
CONFIG_IDF_TARGET_ESP32 ESP32 (classic) Xtensa dual-core Primary target. Has DAC, APLL, I2S ADC mode
CONFIG_IDF_TARGET_ESP32S2 ESP32-S2 Xtensa single-core Limited peripherals. 13-bit ADC
CONFIG_IDF_TARGET_ESP32S3 ESP32-S3 Xtensa dual-core Preferred for large installs. Octal PSRAM, USB-OTG
CONFIG_IDF_TARGET_ESP32C3 ESP32-C3 RISC-V single-core Minimal peripherals. RISC-V clamps out-of-range float→unsigned casts; see cpp.instructions.md UB note
CONFIG_IDF_TARGET_ESP32C5 ESP32-C5 RISC-V single-core Wi-Fi 2.4Ghz + 5Ghz, Thread/Zigbee. Future target
CONFIG_IDF_TARGET_ESP32C6 ESP32-C6 RISC-V single-core Wi-Fi 6, Thread/Zigbee. Future target
CONFIG_IDF_TARGET_ESP32P4 ESP32-P4 RISC-V dual-core High performance. Future target

Build-time validation

WLED validates at compile time that exactly one target is defined and that it is a supported chip (wled.cpp lines 3961). Follow this pattern when adding new chip-specific branches:

#if defined(CONFIG_IDF_TARGET_ESP32)
  // classic ESP32 path
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
  // S3-specific path
#elif defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C5) || defined(CONFIG_IDF_TARGET_ESP32C6)
  // RISC-V common path
#else
  #warning "Untested chip — review peripheral availability"
#endif

Guidelines

  • Always test on the actual chip before claiming support. Simulators and cross-compilation can hide peripheral differences.
  • Prefer #elif chains over nested #ifdef for readability.
  • Do not use CONFIG_IDF_TARGET_* for feature detection. Use SOC_* capability macros instead (see next section). For example, use SOC_I2S_SUPPORTS_ADC instead of CONFIG_IDF_TARGET_ESP32 to check for I2S ADC support.
  • When a feature must be disabled on certain chips, use explicit static_assert() or #warning directives so the build clearly reports what is missing.

Hardware Capability Detection: SOC_* Macros

SOC_* macros (from soc/soc_caps.h) describe what the current chip supports. They are the correct way to check for peripheral features — they stay accurate when new chips are added, unlike CONFIG_IDF_TARGET_* checks.

Important SOC_* macros used in WLED-MM

Macro Type Used in Purpose
SOC_I2S_NUM int audio_source.h Number of I2S peripherals (1 or 2)
SOC_I2S_SUPPORTS_ADC bool usermods/audioreactive/audio_source.h I2S ADC sampling mode (ESP32 only)
SOC_I2S_SUPPORTS_APLL bool usermods/audioreactive/audio_source.h Audio PLL for precise sample rates
SOC_I2S_SUPPORTS_PDM_RX bool usermods/audioreactive/audio_source.h PDM microphone input
SOC_ADC_MAX_BITWIDTH int util.cpp ADC resolution (12 or 13 bits). Renamed to CONFIG_SOC_ADC_RTC_MAX_BITWIDTH in IDF v5
SOC_ADC_CHANNEL_NUM(unit) int pin_manager.cpp ADC channels per unit
SOC_UART_NUM int dmx_input.cpp Number of UART peripherals
SOC_DRAM_LOW / SOC_DRAM_HIGH addr util.cpp DRAM address boundaries for validation

Key pitfall

SOC_ADC_MAX_BITWIDTH (ADC resolution 12 or 13 bits) was renamed to CONFIG_SOC_ADC_RTC_MAX_BITWIDTH in IDF v5.

Less commonly used but valuable

Macro Purpose
SOC_RMT_TX_CANDIDATES_PER_GROUP Number of RMT TX channels (varies 28 by chip)
SOC_LEDC_CHANNEL_NUM Number of LEDC (PWM) channels
SOC_GPIO_PIN_COUNT Total GPIO pin count
SOC_DAC_SUPPORTED Whether the chip has a DAC (ESP32/S2 only)
SOC_SPIRAM_SUPPORTED Whether PSRAM interface exists
SOC_CPU_CORES_NUM Core count (1 or 2) — useful for task pinning decisions

Best practices

// Good: feature-based detection
#if SOC_I2S_SUPPORTS_PDM_RX
  _config.mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM);
#else
  #warning "PDM microphones not supported on this chip"
#endif

// Avoid: chip-name-based detection
#if defined(CONFIG_IDF_TARGET_ESP32) || defined(CONFIG_IDF_TARGET_ESP32S3)
  // happens to be correct today, but breaks when a new chip adds PDM support
#endif

PSRAM capability macros

For PSRAM presence, mode, and DMA access patterns:

Macro Meaning
CONFIG_SPIRAM / BOARD_HAS_PSRAM PSRAM is present in the build configuration
CONFIG_SPIRAM_MODE_QUAD Quad-SPI PSRAM (standard, used on ESP32 classic and some S2/S3 boards)
CONFIG_SPIRAM_MODE_OCT Octal-SPI PSRAM — 8 data lines, DTR mode. Used on ESP32-S3 with octal PSRAM (e.g. N8R8 / N16R8 modules). Reserves GPIO 3337 for the PSRAM bus — do not allocate these pins when this macro is defined. wled.cpp uses this to gate GPIO reservation.
CONFIG_SPIRAM_MODE_HEX Hex-SPI (16-line) PSRAM — future interface on ESP32-P4 running at up to 200 MHz. Used in json.cpp to report the PSRAM mode.
CONFIG_SOC_PSRAM_DMA_CAPABLE PSRAM buffers can be used with DMA (ESP32-S3 with octal PSRAM)
CONFIG_SOC_MEMSPI_FLASH_PSRAM_INDEPENDENT SPI flash and PSRAM on separate buses (no speed contention)

Detecting octal/hex flash

On ESP32-S3 modules with OPI flash (e.g. N8R8 modules where the SPI flash itself runs in Octal-PI mode), the build system sets:

Macro Meaning
CONFIG_ESPTOOLPY_FLASHMODE_OPI Octal-PI flash mode. On S3, implies GPIO 3337 are used by the flash/PSRAM interface — the same GPIO block as octal PSRAM. wled.cpp uses CONFIG_ESPTOOLPY_FLASHMODE_OPI || (CONFIG_SPIRAM_MODE_OCT && BOARD_HAS_PSRAM) to decide whether to reserve these GPIOs. json.cpp uses this to report the flash mode string as "🚀OPI".
CONFIG_ESPTOOLPY_FLASHMODE_HEX Hex flash mode (ESP32-P4). Reported as "🚀🚀HEX" in json.cpp.

Pattern used in WLED-MM (from wled.cpp) to reserve the octal-bus GPIOs on S3:

#if defined(CONFIG_IDF_TARGET_ESP32S3)
  #if CONFIG_ESPTOOLPY_FLASHMODE_OPI || (CONFIG_SPIRAM_MODE_OCT && defined(BOARD_HAS_PSRAM))
    // S3: GPIO 33-37 are used by the octal PSRAM/flash bus
    managed_pin_type pins[] = { {33, true}, {34, true}, {35, true}, {36, true}, {37, true} };
    pinManager.allocateMultiplePins(pins, sizeof(pins)/sizeof(managed_pin_type), PinOwner::SPI_RAM);
  #endif
#endif

ESP-IDF Version Conditionals

Checking the IDF version

#include <esp_idf_version.h>

#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
  // IDF v5+ code path
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0)
  // IDF v4.4+ code path
#else
  // Legacy IDF v3/v4.x path
#endif

Key ESP-IDF version thresholds for WLED-MM

Version What changed
4.0.0 Filesystem API (SPIFFS/LittleFS), GPIO driver overhaul
4.2.0 ADC/GPIO API updates; esp_adc_cal introduced
4.4.0 I2S driver refactored (legacy API remains); adc_deprecated.h headers appear for newer targets
4.4.44.4.8 Known I2S channel-swap regression on ESP32 (workaround in audio_source.h)
5.0.0 Major breaking changes — RMT, I2S, ADC, SPI flash APIs replaced (see migration section)
5.1.0 Matter protocol support; new esp_flash API stable
5.3+ arduino-esp32 v3.x compatibility; C6/P4 support

Guidelines

  • When adding a version guard, always include a comment explaining what changed and why the guard is needed.
  • Avoid version ranges that silently break — prefer >= over exact version matches.
  • Known regressions should use explicit range guards:
    // IDF 4.4.44.4.8 swapped I2S left/right channels (fixed in 4.4.9)
    #if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 4)) && \
        (ESP_IDF_VERSION <= ESP_IDF_VERSION_VAL(4, 4, 8))
      #define I2S_CHANNELS_SWAPPED
    #endif
    

Migrating from ESP-IDF v4.4.x to v5.x

The jump from IDF v4.4 (arduino-esp32 v2.x) to IDF v5.x (arduino-esp32 v3.x) is the largest API break in ESP-IDF history. This section documents the critical changes and recommended migration patterns based on the upstream WLED V5-C6 branch (https://github.com/wled/WLED/tree/V5-C6). Note: WLED-MM has not yet migrated to IDF v5 — these patterns prepare for the future migration.

Compiler changes

IDF v5.x ships a much newer GCC toolchain. Key versions:

ESP-IDF GCC C++ default Notes
4.4.x (current) 8.4.0 C++17 (gnu++17) Xtensa + RISC-V
5.15.3 13.2 C++20 (gnu++2b) Significant warning changes
5.45.5 14.2 C++23 (gnu++2b) Latest; stricter diagnostics

Notable behavioral differences:

Change Impact Action
Stricter -Werror=enum-conversion Implicit int-to-enum casts now error Use explicit static_cast<> or typed enums
C++20/23 features available consteval, concepts, std::span, std::expected Use judiciously — ESP8266 builds still require GCC 10.x with C++17
-Wdeprecated-declarations enforced Deprecated API calls become warnings/errors Migrate to new APIs (see below)
-Wdangling-reference (GCC 13+) Warns when a reference binds to a temporary that will be destroyed Fix the lifetime issue; do not suppress the warning
-fno-common default (GCC 12+) Duplicate tentative definitions across translation units cause linker errors Use extern declarations in headers, define in exactly one .cpp
RISC-V codegen improvements C3/C6/P4 benefit from better register allocation No action needed — automatic

C++ language features: GCC 8 → GCC 14

The jump from GCC 8.4 to GCC 14.2 spans six major compiler releases. This section lists features that become available and patterns that need updating.

Features safe to use after migration

These work in GCC 13+/14+ but not in GCC 8.4. Guard with #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) if the code must compile on both IDF v4 and v5.

Feature Standard Example Benefit
Designated initializers (C++20) C++20 gpio_config_t cfg = { .mode = GPIO_MODE_OUTPUT }; Already used as a GNU extension in GCC 8; becomes standard and portable in C++20
[[likely]] / [[unlikely]] C++20 if (err != ESP_OK) [[unlikely]] { ... } Hints for branch prediction; useful in hot paths
[[nodiscard("reason")]] C++20 [[nodiscard("leak if ignored")]] void* allocBuffer(); Enforces checking return values — helpful for esp_err_t wrappers
std::span<T> C++20 void process(std::span<uint8_t> buf) Safe, non-owning view of contiguous memory — replaces raw pointer + length pairs
consteval C++20 consteval uint32_t packColor(...) Guarantees compile-time evaluation; useful for color constants
constinit C++20 constinit static int counter = 0; Prevents static initialization order fiasco
Concepts / requires C++20 template<typename T> requires std::integral<T> Clearer constraints than SFINAE; improves error messages
Three-way comparison (<=>) C++20 auto operator<=>(const Version&) const = default; Less boilerplate for comparable types
std::bit_cast C++20 float f = std::bit_cast<float>(uint32_val); Type-safe reinterpretation — replaces memcpy or union tricks
if consteval C++23 if consteval { /* compile-time */ } else { /* runtime */ } Cleaner than std::is_constant_evaluated()
std::expected<T, E> C++23 std::expected<int, esp_err_t> readSensor() Monadic error handling — cleaner than returning error codes
std::to_underlying C++23 auto val = std::to_underlying(myEnum); Replaces static_cast<int>(myEnum)

Features already available in GCC 8 (C++17)

These work on both IDF v4.4 and v5.x — prefer them now:

Feature Example Notes
if constexpr if constexpr (sizeof(T) == 4) { ... } Compile-time branching; already used in WLED-MM (see cpp.instructions.md)
std::optional<T> std::optional<uint8_t> pin; Nullable value without sentinel values like -1
std::string_view void log(std::string_view msg) Non-owning, non-allocating string reference
Structured bindings auto [err, value] = readSensor(); Useful with std::pair / std::tuple returns
Fold expressions (addSegment(args), ...); Variadic template expansion
Inline variables inline constexpr int MAX_PINS = 50; Avoids ODR issues with header-defined constants
[[maybe_unused]] [[maybe_unused]] int debug_only = 0; Suppresses unused-variable warnings cleanly
[[fallthrough]] case 1: doA(); [[fallthrough]]; case 2: Documents intentional switch fallthrough
Nested namespaces namespace wled::audio { } Shorter than nested namespace blocks

Patterns that break or change behavior

Pattern GCC 8 behavior GCC 14 behavior Fix
int x; enum E e = x; Warning (often ignored) Error with -Werror=enum-conversion E e = static_cast<E>(x);
int g; in two .cpp files Both compile, linker merges (tentative definition) Error: multiple definitions (-fno-common) extern int g; in header, int g; in one .cpp
const char* ref = std::string(...).c_str(); Silent dangling pointer Warning (-Wdangling-reference) Extend lifetime: store the std::string in a local variable
register int x; Accepted (ignored) Warning or error (register removed in C++17) Remove register keyword
Narrowing in aggregate init Warning Error Use explicit cast or wider type
Implicit this capture in lambdas Accepted in [=] Deprecated warning; error in C++20 mode Use [=, this] or [&]

Recommendations

  • Do not raise the minimum C++ standard yet. WLED-MM must still build on IDF v4.4 (GCC 8.4, C++17). Use #if __cplusplus > 201703L to gate C++20 features.
  • Mark intentional fallthrough with [[fallthrough]] — GCC 14 warns on unmarked fallthrough by default.
  • Prefer std::optional over sentinel values (e.g., -1 for "no pin") in new code — it works on both compilers.
  • Use std::string_view for read-only string parameters instead of const char* or const String& — zero-copy and works on GCC 8+.
  • Avoid raw union type punning — prefer memcpy (GCC 8) or std::bit_cast (GCC 13+) for strict-aliasing safety.

Deprecated and removed APIs

RMT (Remote Control Transceiver)

The legacy rmt_* functions are removed in IDF v5. Do not introduce new legacy RMT calls.

The new API is channel-based:

IDF v4 (legacy) IDF v5 (new) Notes
rmt_config() + rmt_driver_install() rmt_new_tx_channel() / rmt_new_rx_channel() Channels are now objects
rmt_write_items() rmt_transmit() with encoder Requires rmt_encoder_t
rmt_set_idle_level() Configure in channel config Set at creation time
rmt_item32_t rmt_symbol_word_t Different struct layout

WLED impact: NeoPixelBus LED output and IR receiver both use legacy RMT. The upstream V5-C6 branch adds -D WLED_USE_SHARED_RMT and disables IR until the library is ported.

I2S (Inter-IC Sound)

Legacy i2s_driver_install() + i2s_read() API is deprecated. When touching audio source code, wrap legacy I2S init and reading in #if ESP_IDF_VERSION_MAJOR < 5 / #else.

The new API uses channel handles:

IDF v4 (legacy) IDF v5 (new) Notes
i2s_driver_install() i2s_channel_init_std_mode() Separate STD/PDM/TDM modes
i2s_set_pin() Pin config in i2s_std_gpio_config_t Set at init time
i2s_read() i2s_channel_read() Uses channel handle
i2s_set_clk() i2s_channel_reconfig_std_clk() Reconfigure running channel
i2s_config_t i2s_std_config_t Separate config for each mode

Migration pattern (from Espressif examples):

#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
  #include "driver/i2s_std.h"
  i2s_chan_handle_t rx_handle;
  i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
  i2s_new_channel(&chan_cfg, NULL, &rx_handle);

  i2s_std_config_t std_cfg = {
    .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(22050),
    .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO),
    .gpio_cfg = { .din = GPIO_NUM_32, .mclk = I2S_GPIO_UNUSED, ... },
  };
  i2s_channel_init_std_mode(rx_handle, &std_cfg);
  i2s_channel_enable(rx_handle);
#else
  // Legacy i2s_driver_install() path
#endif

WLED impact: The audioreactive usermod (audio_source.h) heavily uses legacy I2S. Migration requires rewriting the I2SSource class for channel-based API.

ADC (Analog-to-Digital Converter)

Legacy adc1_get_raw() and esp_adc_cal_* are deprecated:

IDF v4 (legacy) IDF v5 (new) Notes
adc1_config_width() + adc1_get_raw() adc_oneshot_new_unit() + adc_oneshot_read() Object-based API
esp_adc_cal_characterize() adc_cali_create_scheme_*() Calibration is now scheme-based
adc_continuous_* (old) adc_continuous_* (restructured) Config struct changes

SPI Flash

IDF v4 (legacy) IDF v5 (new)
spi_flash_read() esp_flash_read()
spi_flash_write() esp_flash_write()
spi_flash_erase_range() esp_flash_erase_region()

WLED already has a compatibility shim in ota_update.cpp that maps old names to new ones.

GPIO

IDF v4 (legacy) IDF v5 (recommended)
gpio_pad_select_gpio() esp_rom_gpio_pad_select_gpio() (or use gpio_config())
gpio_set_direction() + gpio_set_pull_mode() gpio_config() with gpio_config_t struct

Features disabled in IDF v5 builds

The upstream V5-C6 branch explicitly disables features with incompatible library dependencies:

# platformio.ini [esp32_idf_V5]
-D WLED_DISABLE_INFRARED       # IR library uses legacy RMT
-D WLED_DISABLE_MQTT            # AsyncMqttClient incompatible with IDF v5
-D ESP32_ARDUINO_NO_RGB_BUILTIN # Prevents RMT driver conflict with built-in LED
-D WLED_USE_SHARED_RMT          # Use new shared RMT driver for NeoPixel output

Migration checklist for new code

  1. Never use a removed API without a version guard. Always provide both old and new paths, or disable the feature on IDF v5.
  2. Test on both IDF v4.4 and v5.x builds if the code must be backward-compatible.
  3. Prefer the newer API when writing new code — wrap the old API in an #else block.
  4. Mark migration TODOs with // TODO(idf5): so they are easy to find later.

Memory Management: heap_caps_* Best Practices

ESP32 has multiple memory regions with different capabilities. Using the right allocator is critical for performance and stability.

Memory regions

Region Flag Speed DMA Size Use for
DRAM MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT Fast Yes (ESP32) 200320 KB Hot-path buffers, task stacks, small allocations
IRAM MALLOC_CAP_EXEC Fastest No 32128 KB Code (automatic via IRAM_ATTR)
PSRAM (SPIRAM) MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT Slower Chip-dependent 216 MB Large buffers, JSON documents, image data
RTC RAM MALLOC_CAP_RTCRAM Moderate No 8 KB Data surviving deep sleep; small persistent buffers

WLED-MM allocation wrappers

WLED-MM provides convenience wrappers with automatic fallback. Always prefer these over raw heap_caps_* calls:

Function Allocation preference Use case
d_malloc(size) RTC → DRAM → PSRAM General-purpose; prefers fast memory
d_calloc(n, size) Same as d_malloc, zero-initialized Arrays, structs
p_malloc(size) PSRAM → DRAM Large buffers; prefers abundant memory
p_calloc(n, size) Same as p_malloc, zero-initialized Large arrays
d_malloc_only(size) RTC → DRAM (no PSRAM fallback) DMA buffers, time-critical data

PSRAM guidelines

  • Check availability: always test psramFound() before assuming PSRAM is present.
  • DMA compatibility: on ESP32 (classic), PSRAM buffers are not DMA-capable — use d_malloc_only() to allocate DMA buffers in DRAM only. On ESP32-S3 with octal PSRAM (CONFIG_SPIRAM_MODE_OCT), PSRAM buffers can be used with DMA when CONFIG_SOC_PSRAM_DMA_CAPABLE is defined.
  • JSON documents: use the PSRAMDynamicJsonDocument allocator (defined in wled.h) to put large JSON documents in PSRAM:
    PSRAMDynamicJsonDocument doc(16384);  // allocated in PSRAM if available
    
  • Fragmentation: PSRAM allocations fragment less than DRAM because the region is larger. But avoid mixing small and large allocations in PSRAM — small allocations waste the MMU page granularity.
  • Heap validation: use d_measureHeap() and d_measureContiguousFreeHeap() to monitor remaining DRAM. Allocations that would drop free DRAM below MIN_HEAP_SIZE should go to PSRAM instead.
  • Performance: Keep hot-path data in DRAM. Prefer PSRAM for capacity-oriented buffers and monitor contiguous DRAM headroom.

PSRAM access is up to 15× slower than DRAM on ESP32, 310× slower than DRAM on ESP32-S3/-S2 with quad-SPI bus. On ESP32-S3 with octal PSRAM (CONFIG_SPIRAM_MODE_OCT), the penalty is smaller (~2×) because the 8-line DTR bus can transfer 8 bits in parallel at 80 MHz (120 MHz is possible with CONFIG_SPIRAM_SPEED_120M, which requires enabling experimental ESP-IDF features). On ESP32-P4 with hex PSRAM (CONFIG_SPIRAM_MODE_HEX), the 16-line bus runs at 200 MHz which brings it on-par with DRAM. Keep hot-path data in DRAM regardless, but consider that ESP32 often crashes when the largest DRAM chunk gets below 10 KB.

Pattern: preference-based allocation

When you need a buffer that works on boards with or without PSRAM:

// Prefer PSRAM for large buffers, fall back to DRAM
uint8_t* buf = (uint8_t*)heap_caps_malloc_prefer(bufSize, 2,
    MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,   // first choice: PSRAM
    MALLOC_CAP_DEFAULT);                    // fallback: any available
// Or simply:
uint8_t* buf = (uint8_t*)p_malloc(bufSize);

I2S Audio: Best Practices

The audioreactive usermod uses I2S for microphone input. Key patterns:

Port selection

constexpr i2s_port_t AR_I2S_PORT = I2S_NUM_0;
// I2S_NUM_1 has limitations: no MCLK routing, no ADC support, no PDM support

Always use I2S_NUM_0 unless you have a specific reason and have verified support on all target chips.

DMA buffer tuning

DMA buffer size controls latency vs. reliability:

Scenario dma_buf_count dma_buf_len Latency Notes
With HUB75 matrix 18 128 ~100 ms Higher count prevents I2S starvation during matrix DMA
Without PSRAM 24 128 ~140 ms More buffers compensate for slower interrupt response
Default 8 128 ~46 ms Acceptable for most setups

Interrupt priority

Choose interrupt priority based on coexistence with other drivers:

#ifdef WLED_ENABLE_HUB75MATRIX
  .intr_alloc_flags = ESP_INTR_FLAG_IRAM | ESP_INTR_FLAG_LEVEL1,  // level 1 (lowest) to avoid starving HUB75
#else
  .intr_alloc_flags = ESP_INTR_FLAG_LEVEL2 | ESP_INTR_FLAG_LEVEL3,  // accept level 2 or 3 (allocator picks available)
#endif

APLL (Audio PLL) usage

The ESP32 has an audio PLL for precise sample rates. Rules:

  • Enable APLL when an MCLK pin is provided and precision matters.
  • Disable APLL when Ethernet or HUB75 is active — they also use the APLL.
  • APLL is broken on ESP32 revision 0 silicon.
  • Not all chips have APLL — gate with SOC_I2S_SUPPORTS_APLL.
#if !defined(SOC_I2S_SUPPORTS_APLL)
  _config.use_apll = false;
#elif defined(WLED_USE_ETHERNET) || defined(WLED_ENABLE_HUB75MATRIX)
  _config.use_apll = false;  // APLL conflict
#endif

PDM microphone caveats

  • Not supported on ESP32-C3 (SOC_I2S_SUPPORTS_PDM_RX not defined).
  • ESP32-S3 PDM has known issues: sample rate at 50% of expected, very low amplitude.
  • No clock pin (I2S_CKPIN = -1) triggers PDM mode in WLED-MM.

HUB75 LED Matrix: Best Practices

WLED-MM uses the ESP32-HUB75-MatrixPanel-I2S-DMA library for HUB75 matrix output.

Chip-specific panel limits

#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)
  maxChainLength = 6;   // S3 + PSRAM: up to 6 panels (DMA-capable PSRAM)
#elif defined(CONFIG_IDF_TARGET_ESP32S2)
  maxChainLength = 2;   // S2: limited DMA channels
#else
  maxChainLength = 4;   // Classic ESP32: default
#endif

Color depth vs. pixel count

The driver dynamically reduces color depth for larger displays to stay within DMA buffer limits:

Pixel count Color depth Bits per pixel
MAX_PIXELS_10BIT 10-bit (30-bit color) High quality (experimental)
MAX_PIXELS_8BIT 8-bit (24-bit color) Full quality
MAX_PIXELS_6BIT 6-bit (18-bit color) Slight banding
MAX_PIXELS_4BIT 4-bit (12-bit color) Visible banding
larger 3-bit (9-bit color) Minimal color range

Resource conflicts

  • APLL: HUB75 I2S DMA uses the APLL. Disable APLL in the audio I2S driver when HUB75 is active.
  • I2S peripheral: HUB75 uses I2S_NUM_1 (or I2S_NUM_0 on single-I2S chips). Audio must use the other port.
  • Pin count: HUB75 requires 1314 GPIO pins. On ESP32-S2 this severely limits remaining GPIO.
  • Reboot required: on ESP32-S3, changing HUB75 driver options requires a full reboot — the I2S DMA cannot be reconfigured at runtime.

GPIO Best Practices

Prefer gpio_config() over individual calls

// Preferred: single struct-based configuration
gpio_config_t io_conf = {
  .pin_bit_mask = (1ULL << pin),
  .mode         = GPIO_MODE_OUTPUT,
  .pull_up_en   = GPIO_PULLUP_DISABLE,
  .pull_down_en = GPIO_PULLDOWN_DISABLE,
  .intr_type    = GPIO_INTR_DISABLE,
};
gpio_config(&io_conf);

// Avoid: multiple separate calls (more error-prone, deprecated in IDF v5)
gpio_set_direction(pin, GPIO_MODE_OUTPUT);
gpio_set_pull_mode(pin, GPIO_FLOATING);

Pin manager integration

Always allocate pins through WLED's pinManager before using GPIO APIs:

if (!pinManager.allocatePin(myPin, true, PinOwner::UM_MyUsermod)) {
  return;  // pin in use by another module
}
// Now safe to configure

Timer Best Practices

Microsecond timing

For high-resolution timing, prefer esp_timer_get_time() (microsecond resolution, 64-bit) over millis() or micros():

#include <esp_timer.h>
int64_t now_us = esp_timer_get_time();  // monotonic, not affected by NTP

Periodic timers

For periodic tasks with sub-millisecond precision, use esp_timer:

esp_timer_handle_t timer;
esp_timer_create_args_t args = {
  .callback = myCallback,
  .arg = nullptr,
  .dispatch_method = ESP_TIMER_TASK,  // run in timer task (not ISR)
  .name = "my_timer",
};
esp_timer_create(&args, &timer);
esp_timer_start_periodic(timer, 1000);  // 1 ms period

Always prefer ESP_TIMER_TASK dispatch over ESP_TIMER_ISR unless you need ISR-level latency — ISR callbacks have severe restrictions (no logging, no heap allocation, no FreeRTOS API calls).


ADC Best Practices

Version-aware ADC code

ADC is one of the most fragmented APIs across IDF versions:

#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
  // IDF v5: oneshot driver
  #include "esp_adc/adc_oneshot.h"
  #include "esp_adc/adc_cali.h"
  adc_oneshot_unit_handle_t adc_handle;
  adc_oneshot_unit_init_cfg_t unit_cfg = { .unit_id = ADC_UNIT_1 };
  adc_oneshot_new_unit(&unit_cfg, &adc_handle);
#else
  // IDF v4: legacy driver
  #include "driver/adc.h"
  #include "esp_adc_cal.h"
  adc1_config_width(ADC_WIDTH_BIT_12);
  int raw = adc1_get_raw(ADC1_CHANNEL_0);
#endif

Bit width portability

Not all chips have 12-bit ADC. SOC_ADC_MAX_BITWIDTH reports the maximum resolution (12 or 13 bits). Note that in IDF v5, this macro was renamed to CONFIG_SOC_ADC_RTC_MAX_BITWIDTH. Write version-aware guards:

// IDF v4: SOC_ADC_MAX_BITWIDTH   IDF v5: CONFIG_SOC_ADC_RTC_MAX_BITWIDTH
#if defined(CONFIG_SOC_ADC_RTC_MAX_BITWIDTH)   // IDF v5+
  #define MY_ADC_MAX_BITWIDTH CONFIG_SOC_ADC_RTC_MAX_BITWIDTH
#elif defined(SOC_ADC_MAX_BITWIDTH)             // IDF v4
  #define MY_ADC_MAX_BITWIDTH SOC_ADC_MAX_BITWIDTH
#else
  #define MY_ADC_MAX_BITWIDTH 12                // safe fallback
#endif

#if MY_ADC_MAX_BITWIDTH == 13
  adc1_config_width(ADC_WIDTH_BIT_13);  // ESP32-S2
#else
  adc1_config_width(ADC_WIDTH_BIT_12);  // ESP32, S3, C3, etc.
#endif

WLED-MM's util.cpp uses the IDF v4 form (SOC_ADC_MAX_BITWIDTH) — this will need updating when the codebase migrates to IDF v5.


RMT Best Practices

Current usage in WLED

RMT drives NeoPixel LED output (via NeoPixelBus) and IR receiver input. Both use the legacy API that is removed in IDF v5.

Migration notes

  • The upstream V5-C6 branch uses -D WLED_USE_SHARED_RMT to switch to the new RMT driver for NeoPixel output.
  • IR is disabled on IDF v5 until the IR library is ported.
  • New chips (C6, P4) have different RMT channel counts — use SOC_RMT_TX_CANDIDATES_PER_GROUP to check availability.
  • The new RMT API requires an "encoder" object (rmt_encoder_t) to translate data formats — this is more flexible but requires more setup code.

Espressif Best Practices (from official examples)

Error handling

Always check esp_err_t return values. Use ESP_ERROR_CHECK() in initialization code, but handle errors gracefully in runtime code:

// Initialization — crash early on failure
ESP_ERROR_CHECK(i2s_driver_install(I2S_NUM_0, &config, 0, nullptr));

// Runtime — log and recover
esp_err_t err = i2s_read(I2S_NUM_0, buf, len, &bytes_read, portMAX_DELAY);
if (err != ESP_OK) {
  DEBUGSR_PRINTF("I2S read failed: %s\n", esp_err_to_name(err));
  return;
}

Logging

WLED-MM uses its own logging macros — not ESP_LOGx(). For application-level code, always use the WLED-MM macros defined in wled.h:

Macro family Defined in Controlled by Use for
USER_PRINT / USER_PRINTLN / USER_PRINTF wled.h Always active Important messages the user should see (startup, errors, status)
DEBUG_PRINT / DEBUG_PRINTLN / DEBUG_PRINTF wled.h WLED_DEBUG build flag Development/diagnostic output; compiled out in release builds

All of these wrap Serial output through the DEBUGOUT / DEBUGOUTLN / DEBUGOUTF macros.

Exception — low-level driver code: When writing code that interacts directly with ESP-IDF APIs (e.g., I2S initialization, RMT setup), use ESP_LOGx() macros instead. They support tag-based filtering and compile-time log level control:

static const char* TAG = "my_module";
ESP_LOGI(TAG, "Initialized with %d buffers", count);
ESP_LOGW(TAG, "PSRAM not available, falling back to DRAM");
ESP_LOGE(TAG, "Failed to allocate %u bytes", size);

Task creation and pinning

On dual-core chips (ESP32, S3, P4), pin latency-sensitive tasks to a specific core:

xTaskCreatePinnedToCore(
  audioTask,        // function
  "audio",          // name
  4096,             // stack size
  nullptr,          // parameter
  5,                // priority (higher = more important)
  &audioTaskHandle, // handle
  0                 // core ID (0 = protocol core, 1 = app core)
);

Guidelines:

  • Pin network/protocol tasks to core 0 (where Wi-Fi runs).
  • Pin real-time tasks (audio, LED output) to core 1.
  • On single-core chips (S2, C3, C5, C6), only core 0 exists — pinning to core 1 will fail. Use SOC_CPU_CORES_NUM > 1 guards or tskNO_AFFINITY.
  • Use SOC_CPU_CORES_NUM to conditionally pin tasks:
    #if SOC_CPU_CORES_NUM > 1
      xTaskCreatePinnedToCore(audioTask, "audio", 4096, nullptr, 5, &handle, 1);
    #else
      xTaskCreate(audioTask, "audio", 4096, nullptr, 5, &handle);
    #endif
    

Tip: use xTaskCreateUniversal() - from arduino-esp32 - to avoid the conditional on SOC_CPU_CORES_NUM. This function has the same signature as xTaskCreatePinnedToCore(), but automatically falls back to xTaskCreate() on single-core MCUs.

delay(), yield(), and the IDLE task

FreeRTOS on ESP32 is preemptive — all tasks are scheduled by priority regardless of yield() calls. This is fundamentally different from ESP8266 cooperative multitasking.

Call What it does Reaches IDLE (priority 0)?
delay(ms) / vTaskDelay(ticks) Suspends calling task; scheduler runs all other ready tasks Yes
yield() / vTaskDelay(0) Hint to switch to tasks at equal or higher priority only No
taskYIELD() Same as vTaskDelay(0) No
Blocking API (xQueueReceive, ulTaskNotifyTake, vTaskDelayUntil) Suspends task until event or timeout; IDLE runs freely Yes

delay() in loopTask is safe. Arduino's loop() runs inside loopTask. Calling delay() suspends only loopTask — all other FreeRTOS tasks (Wi-Fi stack, audio FFT, LED DMA) continue uninterrupted on either core.

yield() does not yield to IDLE. Any task that loops with only yield() calls will starve the IDLE task, causing the IDLE watchdog to fire. Always use delay(1) (or a blocking FreeRTOS call) in tight task loops. Note: WLED-MM redefines yield() as an empty macro on ESP32 WLEDMM_FASTPATH builds — see cpp.instructions.md.

Why the IDLE task is not optional

The FreeRTOS IDLE task (one per core on dual-core ESP32 and ESP32-S3; single instance on single-core chips) is not idle in the casual sense — it performs essential system housekeeping:

  • Frees deleted task memory: when a task calls vTaskDelete(), the IDLE task reclaims its TCB and stack. Without IDLE running, deleted tasks leak memory permanently.
  • Runs the idle hook: when configUSE_IDLE_HOOK = 1, the IDLE task calls vApplicationIdleHook() on every iteration — some ESP-IDF components register low-priority background work here.
  • Implements tickless idle / light sleep: on battery-powered devices, IDLE is the entry point for low-power sleep. A permanently starved IDLE task disables light sleep entirely.
  • Runs registered idle hooks: ESP-IDF components register callbacks via esp_register_freertos_idle_hook() (e.g., Wi-Fi background maintenance, Bluetooth housekeeping). These only fire when IDLE runs.

In short: starving IDLE corrupts memory cleanup, breaks background activities, disables low-power sleep, and prevents Wi-Fi/BT maintenance. The IDLE watchdog panic is a symptom — the real damage happens before the watchdog fires.

Watchdog management

Long-running operations may trigger the task watchdog. Feed it explicitly:

#include <esp_task_wdt.h>
esp_task_wdt_reset();  // feed the watchdog in long loops

For tasks that intentionally block for extended periods, consider subscribing/unsubscribing from the TWDT:

esp_task_wdt_delete(NULL);  // remove current task from TWDT (IDF v4.4)
// ... long blocking operation ...
esp_task_wdt_add(NULL);     // re-register

IDF v5 note: In IDF v5, esp_task_wdt_add() and esp_task_wdt_delete() require an explicit TaskHandle_t. Use xTaskGetCurrentTaskHandle() instead of NULL.


Quick Reference: IDF v4 → v5 API Mapping

Component IDF v4 Header IDF v5 Header Key Change
I2S driver/i2s.h driver/i2s_std.h Channel-based API
ADC (oneshot) driver/adc.h esp_adc/adc_oneshot.h Unit/channel handles
ADC (calibration) esp_adc_cal.h esp_adc/adc_cali.h Scheme-based calibration
RMT driver/rmt.h driver/rmt_tx.h / rmt_rx.h Encoder-based transmit
SPI Flash spi_flash.h esp_flash.h esp_flash_* functions
GPIO driver/gpio.h driver/gpio.h gpio_pad_select_gpio() removed
Timer driver/timer.h driver/gptimer.h General-purpose timer handles
PCNT driver/pcnt.h driver/pulse_cnt.h Handle-based API