I am convinced that modern embedded framework development is less about engineering and more about surviving a sequence of silently laid traps. I spent this past weekend trying to bring up code in src/radio on the new M5Stack Tab5—featuring the dual-architecture marriage of an ESP32-P4 and an ESP32-C6 Wi-Fi co-processor.
What should have been a straightforward exploration of a brand-new RISC-V application processor instead devolved into a multi-layered diagnostic nightmare. By Sunday night, I wasn’t just debugging code; I was literally jump-starting a stone-dead lithium-ion battery on a benchtop supply because the vendor’s Board Support Package (BSP) had silently signed its death warrant.
If you enjoy detective stories where the murder weapon is a rogue I2C initialization sequence, pull up a chair.
Layer 1: The Kconfig Mirage and the Missing Environment Variable
The fun starts before you even talk to hardware. When trying to compile the espressif__esp_hosted component to let the P4 talk to the C6, the toolchain grinds to a halt with a spectacular piece of technical gaslighting:
managed_components/espressif__esp_hosted/host/api/include/esp_hosted_config.h:59:2:
error: #error "Unknown Slave Target"
Why doesn’t it know what an ESP32-C6 is? Because espressif__esp_wifi_remote relies on a specific environment variable ($ESP_IDF_VERSION) to locate its own target definition files.
If you are running ESP-IDF 5.5.4, the native export script dutifully sets IDF_VERSION=5.5.4. But the component’s internal CMake logic is hardcoded to look for a truncated $ESP_IDF_VERSION=5.5. If that exact variable isn’t present, the build system doesn’t throw a warning. It doesn’t halt. It just silently skips sourcing the slave target definitions. The component ships broken out of the box.
The Canonical Recipe: You must manually export ESP_IDF_VERSION=5.5 before running idf.py build.
Layer 2: The Silicon Revision Time Machine
Once the build system stops hallucinating, you run into the next silent trap: the ESP-IDF defaults the ESP32-P4 to silicon revision 3.1.
This is fascinating because revision 3.1 hasn’t even shipped yet. The M5Stack Tab5 uses revision 1.0.
If you try to fix this in sdkconfig.defaults, you’ll quickly learn that Kconfig choice menus are notoriously sticky. Even with the right value in defaults, the choice will silently revert to the toolchain’s winner on every target set. Furthermore, the bootloader is built as an ExternalProject, meaning it doesn’t cleanly propagate sdkconfig changes through normal build invalidation.
To break out of the time machine and talk to the hardware currently sitting on your desk, you have to find the master boolean override: SELECTS_REV_LESS_V3=y in your sdkconfig.defaults.
When you build, check your actual active config:
grep CONFIG_ESP32P4_REV_MIN_100 sdkconfig
Only when that search returns =y (rather than being absent or # NOT SET) are you actually done with the toolchain and talking to the Rev 1.0 hardware.
Layer 3: The Wi-Fi Stub Illusion and the Missing Link
The ESP32-P4 has absolutely no Wi-Fi silicon on board. Yet, calling esp_wifi_init() runs flawlessly. No error codes, no diagnostics—just an empty void of silent zeroes returning from your APIs.
In our early efforts to see a firmware version on the C6 co-processor and read its MAC address, we hit this wall of zeroes. We initially misdiagnosed it as a transient power rail issue, assuming the C6 wasn’t waking up in time. To make sure it was awake, we reached into the BSP and enabled the Wi-Fi feature explicitly:
// Initialize IO Expander 1 (which hosts C6 power enable)
bsp_io_expander1_init();
// Power on the ESP32-C6 Wi-Fi co-processor via the BSP
ESP_LOGI("WiFiSystem", "Enabling ESP32-C6 Wi-Fi co-processor power...");
esp_err_t wifi_en_err = bsp_feature_enable(BSP_FEATURE_WIFI, true);
Spoiler: power wasn’t the issue. The real reason the C6 was acting like a ghost was a software missing link. To stop ESP-IDF from linking its dummy stubs and actually route the Wi-Fi API calls to the physical C6, you have to ensure esp_hosted is properly declared and that CONFIG_SLAVE_IDF_TARGET_ESP32C6 is actively selected. Once we fixed the component linking, the C6 woke up, returned a real MAC address, and we moved on.
But little did we know, that vestigial snippet of power-enable code we added during our misdiagnosis was hanging on the wall like Chekhov’s gun, waiting to fire in the second act.
Layer 4: The Silence of the Chargers
A day later, the real mystery began. I unplugged the Tab5 from my development host, and the device instantly died.
I pulled out the multi-meter. The battery is reading 0.0V. Not low. Not flagging. Monty Python parrot dead. I hooked it up to the benchtop supply, cranked it to 7.5V with a few hundred mA, and effectively jump-started the cells.
(Sidebar: If you find yourself doing this, limit your current and touch the cell occasionally to monitor its temperature. Having previously set a Li-Ion battery ablaze—a smell that never quite leaves your workspace—I was concerned, but apparently not concerned enough to not do it.)
The jump-start was successful! I did a quick test, removed the cable, and watched the device “coast” on battery for a few minutes. This gave me a false sense of security. I figured the battery was just having a bad day, and that leaving it plugged into my host for further development would charge it responsibly—much like joyriding in a car after jump-starting it to heal the battery with a charge from the alternator.
Demo time! Unplug from the computer… instant death. Back to 0.0V.
Naturally, it was time to look at the telemetry. Espressif has APIs for charging states and voltages, but in keeping with current traditions, they aren’t actually implemented for this board/chip combo. I glanced at the schematics, saw a plain old INA226 monitoring the rails, knocked out some quick boilerplate driver code, and read the registers directly.
The data was immediately whackadoodle:
- With the battery removed, it read a floating phantom voltage of 1.08V.
- Plug the battery in, and it drops to 0.
- The
isChargingbit was completely inverted because—in a stunning display of hardware engineering—they hooked the INA226 up backward.
But even after accounting for the backward bit, the chip was reading 0mA of charge current. Why? Because there was 0V being delivered to the battery terminals. I beat the local I2C I/O expander at address 0x44 with the biggest stick possible, forcing the charge enable (CHG_EN) bits high:
// Expander 0x44: CHG_EN, CHG_STAT, QC_EN, PWROFF, USB5V, WLAN_PWR.
// OUT_SET first: CHG_EN(7)=1, nCHG_QC_EN(5)=0 (QC enabled), WLAN_PWR_EN(0)=1.
write_expander_reg(bus, 0x44, IOEXP_REG_OUT_SET, 0b10000001); // CHG_EN (bit 7) = 1
// Verify CHG_EN actually landed
uint8_t out_set_readback = read_expander_reg(bus, 0x44, IOEXP_REG_OUT_SET);
ESP_LOGI(HW_TAG, "Expander 0x44 OUT_SET readback: 0x%02x", out_set_readback);
The register readback confirmed bit 7 was high. Yet, the battery terminals remained completely cold.
The Whodunit Revealed: Death by Global Reset
Here is the anatomy of the crime. Remember that initialization code from Layer 3?
Deep inside managed_components/espressif__m5stack_tab5/src/bsp_feature_en.c, the function bsp_feature_enable(BSP_FEATURE_WIFI, true) invokes bsp_io_expander1_init(). This internally configures the PI4IOE5V6408 driver for the I2C expander at 0x44.
The upstream driver’s initialization sequence issues a global reset to the expander chip.
A global reset reverts every single register on the multiplexer to power-on defaults. Every pin is wiped back to an input, and the output registers are totally zeroed out. This means every time the BSP touches the Wi-Fi feature, it silently obliterates the CHG_EN bit bit that tells the hardware charger IC to feed the battery. But it gets worse. That expander chip is the central nervous system for the board’s power. By resetting it, enabling the Wi-Fi using the vendor’s documented interface, the Wi-Fi initialization sequence is also acting as a serial killer for the USB 5V output, the Quick Charge negotiation, and the main system power-off latch.
[Your Code] -> Asserts CHG_EN (Battery tries to live
↓
[BSP Wi-Fi Init] -> Global Reset to Expander 0x44 -> CHG_EN = 0 (Silent Death)
The Post-Mortem Fixes
After a full day in this architectural loop of despair, the fix required two distinct steps:
- Purge the vestigial BSP Wi-Fi code. Getting rid of
bsp_feature_enablestops the hardware expander from resetting itself into oblivion. - Sanitize the telemetry logic. Invert the backward current direction on the INA226. Ignore any phantom voltage under 1.06V. Treat each divisor as exactly 1.25mV, and bypass any broken external scalers.
With those changes committed, reality has returned to the workbench. The telemetry now shows a responsible, slowly rising battery voltage, a couple hundred mA of charge current that drops cleanly to zero when full, and a steady 200mA discharge when the dual CPUs and Wi-Fi are cooking with the screen off. A full charge sits at 8216mV—spot-on for a 2S 18650 pack.
I’ve committed the fixes, documented the insanity in sdkconfig.defaults and AGENTS.md, and we will never lose this ground again. But let this be a lesson: if your ESP-IDF project is behaving weirdly, check your environment variables—and then check if your toolchain is trying to fire-sale your lithium infrastructure.
The TL;DR: ESP-IDF 5.5 / Tab5 Survival Checklist
To tie a bow on this murder mystery, here is the master punchlist for the next poor soul trying to get Wi-Fi working on an ESP32-P4 without accidentally executing their battery:
- The Environment Variable Penalty: You must
export ESP_IDF_VERSION=5.5before every single invocation ofidf.py. If you forget, the build system detects the shift from 5.5 back to 5.5.4, assumes you are changing toolchains, and cheerfully triggers a 2,500-file recompile to punish you. - The Component Linkage: Adding the
espressif__esp_hostedcomponent to your build isn’t enough. You must explicitly enable it in the configuration (CONFIG_SLAVE_IDF_TARGET_ESP32C6=y). If you skip this, the framework silently links the dummy stubs, leaving you staring at a wall of zeroes. - The Silicon Revision Trap: Avoid the attractive nuisance of trying to manually set
CONFIG_ESP32P4_REV_MIN_0,CONFIG_ESP_REV_MIN_FULL, or related variables. They are deeply intertwined, and Kconfig will stubbornly overwrite your choices with its own broken defaults. You must assert the master boolean (CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y) to force it to respect Rev 1.0 hardware. - The Battery Assassin: Never call
bsp_feature_enable(BSP_FEATURE_WIFI, true). The resulting global I2C reset will nuke your battery charger, your USB 5V output, and your system power-off latch in one fell swoop.