I thought I was being clever. The ESP32 only wakes from deep sleep on specific RTC GPIOs, and I was already using GPIO 13 for I2C SCL. Why not reuse it as the wake signal too?
Turns out, reusing GPIO 13 for both I2C clock and interrupt wake creates a fundamental deadlock. The MPU-9250 motion sensor pulls the interrupt line LOW to signal motion. But that interrupt line is also SCL. When SCL is held LOW, I2C can’t communicate. When I2C can’t communicate, you can’t clear the interrupt. When you can’t clear the interrupt, SCL stays LOW forever.
The symptom? My motion sensor would trigger once, then never wake again. The ESP32 would fall back to a 60-second timer wake, making the whole motion detection system useless.
The SCL Trick That Wasn’t So Clever
The original design looked elegant on paper. Here’s what I thought would happen:
- Before sleep, configure the MPU’s interrupt pin as open-drain, active-low
- Enable Wake-on-Motion (WoM) detection with a threshold of 100mg
- Put the ESP32 in deep sleep with GPIO 13 as the ext0 wake source
- When motion occurs, MPU pulls SCL low
- ESP32 wakes up, immediately disables the WoM interrupt
- SCL is released, I2C works normally again
Here’s what actually happened:
- Motion detected, MPU pulls GPIO 13 LOW
- ESP32 wakes from deep sleep
- Try to init I2C to disable the interrupt
- SCL is stuck LOW because the interrupt is still active
- I2C times out after several seconds
- Can’t clear the interrupt because I2C doesn’t work
- Next sleep cycle uses timer wake (60 seconds) as fallback
- Motion detection effectively broken
The logs were brutal:
I (1253) mpu9250: SCL initial state with pull-up: 0
I (1281) mpu9250: BB: NACK at addr 0x68
W (1291) mpu9250: BB: MPU not responding to I2C
E (2306) i2c.master: I2C software timeout
E (2306) mpu9250: Failed to read WHO_AM_I
Timer Polling to the Rescue
The fix was to stop fighting the hardware and work with it instead. Instead of trying to use the interrupt line for wake, I switched to timer-based polling.
The new approach is dead simple:
- ESP32 wakes every 2 seconds on a timer
- Quick I2C read of the MPU’s INT_STATUS register (~30ms)
- Check if the WoM flag (0x40) is set
- If motion detected, take a photo
- If no motion, go right back to sleep
// In mpu9250_check_motion()
uint8_t int_status = 0;
esp_err_t ret = mpu9250_read_reg(REG_INT_STATUS, &int_status);
if (ret == ESP_OK && (int_status & INT_STATUS_WOM_INT)) {
ESP_LOGI(TAG, "Motion detected!");
return true;
}
return false;
The power numbers are actually really good. At 2-second poll intervals:
- Average current: ~200μA
- Battery life on 1000mAh: ~208 days standby
- Response latency: 0-2 seconds
Compare that to the “clever” ext0 wake approach that didn’t work at all, and polling wins every time.
Making It Configurable
I made the polling interval configurable via menuconfig so you can tune the trade-off between response time and battery life:
// In Kconfig.projbuild
config MOTION_POLL_INTERVAL_MS
int "Motion polling interval (milliseconds)"
default 2000
range 500 30000
help
How often to wake and check for motion.
Lower = faster response, higher power consumption.
The range is 500ms to 30 seconds. At 500ms you get near-instant response but cut battery life to about 100 days. At 30 seconds you get a year of battery life but won’t catch quick movements.
I settled on 2 seconds as the sweet spot. Fast enough to feel responsive, efficient enough to run for months on a small battery.
The Clone Chip Problem
There’s another wrinkle worth mentioning. The MPU-9250 I’m using reports WHO_AM_I as 0x70, not the expected 0x71. It’s probably a clone chip, and clone chips don’t always implement the interrupt modes correctly.
Even if the SCL trick could work in theory with a genuine Invensense chip, it definitely won’t work with clones that don’t properly support open-drain, active-low interrupt mode. The push-pull default means the interrupt actively drives SCL low instead of gently pulling it with an open drain.
Timer polling doesn’t care about any of that. The interrupt flag still gets set internally, we just read it over I2C instead of waiting for a GPIO edge.
Lessons Learned
Don’t be too clever with GPIO reuse. Sometimes the simple solution (timer polling) is more robust than the “elegant” solution (dual-purpose GPIO).
Test your assumptions about hardware behavior. I assumed the interrupt would be easy to clear after wake. I was wrong.
Power consumption math isn’t always intuitive. Waking every 2 seconds sounds wasteful, but at 30ms active time per wake, you’re only active 1.5% of the time. Deep sleep current dominates the calculation.
Clone chips are a reality in hobby electronics. Design your firmware to work around their quirks rather than depending on spec-perfect behavior.
The timer polling approach is now the default in the firmware, and the old ext0 wake mode is still available but documented as “legacy, unreliable.” I should have started with polling from the beginning!!