Nov 30, 2025
6 min read

The Documentation Lied: Debugging a Hardware Conflict That Didn't Exist

900 lines of bit-bang I2C recovery code to work around a GPIO conflict that was never actually wired on the hardware.

The documentation said the INT pin was connected to GPIO 13. The documentation said this would conflict with I2C SCL. The documentation was wrong.

I spent days writing complex workarounds for a problem that didn’t exist. Bit-bang I2C with push-pull override. 16+ clock pulse bus recovery sequences. GPIO state manipulation before I2C init. None of it worked because the problem wasn’t real.

The actual problem? My workarounds were breaking perfectly functional hardware.

What the Docs Claimed

The CLAUDE.md file in the project was very clear about the hardware issue:

“The MPU’s INT pin (connected to GPIO 13) defaults to push-pull mode, actively driving GPIO 13 LOW. This conflicts with using GPIO 13 as I2C SCL, preventing any I2C communication with the MPU.”

Based on this, the codebase had evolved into a monstrosity of recovery logic. Here’s what mpu9250.c looked like:

  • GPIO state checking and manipulation before I2C init
  • Bit-bang I2C with manual SCL override using max drive strength
  • Bus recovery with repeated clock pulses
  • Multiple fallback modes when communication failed
  • Push-pull configuration to override the “stuck” SCL line

It was nearly 900 lines of complexity trying to work around a fundamental hardware conflict.

The Actual Hardware

The M5Stack Timer Camera X uses a standard HY2.0-4P connector for external I2C devices. Here’s what’s actually on that connector:

  1. GND
  2. VCC (5V)
  3. SDA (GPIO 4)
  4. SCL (GPIO 13)

That’s it. Four wires. There is no INT wire connected to GPIO 13. The INT/SCL conflict described in the documentation either came from a different hardware variant, or was an assumption that nobody ever validated with a multimeter.

The Real Problem

The complex bus recovery code wasn’t fixing I2C communication. It was breaking it.

Here’s what was happening:

  1. GPIO manipulation before I2C init corrupted the bus state
  2. Bit-bang attempts interfered with the I2C driver’s initialization
  3. The “recovery” logic created the exact problem it was trying to solve
  4. Every I2C read failed with timeouts and NACKs

The logs showed the symptoms:

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

I was reading SCL as LOW during init and thinking “aha, it’s stuck!” But it was only stuck because my GPIO manipulation had put it in a weird state.

The Solution: Delete Everything

I stripped out all the recovery logic. All the bit-banging. All the GPIO manipulation. I replaced 900 lines of complex I2C handling with about 50 lines of standard ESP-IDF I2C code:

// Initialize I2C master
i2c_master_bus_config_t bus_config = {
    .i2c_port = I2C_NUM_0,
    .sda_io_num = 4,
    .scl_io_num = 13,
    .clk_source = I2C_CLK_SRC_DEFAULT,
    .glitch_ignore_cnt = 7,
    .flags.enable_internal_pullup = true,
};
esp_err_t ret = i2c_new_master_bus(&bus_config, &bus);
if (ret != ESP_OK) {
    ESP_LOGE(TAG, "Failed to init I2C bus: %s", esp_err_to_name(ret));
    return ret;
}

// Add device
i2c_device_config_t dev_config = {
    .dev_addr_length = I2C_ADDR_BIT_LEN_7,
    .device_address = 0x68,
    .scl_speed_hz = 400000,
};
ret = i2c_master_bus_add_device(bus, &dev_config, &mpu);

That’s it. No tricks, no recovery, no bit-banging. Just the standard ESP-IDF I2C driver doing what it’s designed to do.

The result:

I (1249) mpu9250: WHO_AM_I: 0x70 (expected 0x71 for MPU-9250)
I (1363) mpu9250: MPU initialized successfully
I (1364) mpu9250: Gyro: X=-6 Y=+0 Z=+0 dps | Accel: X=+921 Y=-256 Z=-423 mg

Perfect. The MPU had been working fine the entire time. I just needed to get out of its way.

How to Actually Debug I2C

Here’s what I should have done from the beginning:

Step 1: Check GPIO levels

gpio_config_t io_conf = {
    .pin_bit_mask = (1ULL << SDA_GPIO) | (1ULL << SCL_GPIO),
    .mode = GPIO_MODE_INPUT,
    .pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io_conf);

// Both should read HIGH (1) when bus is idle
ESP_LOGI(TAG, "SDA: %d, SCL: %d",
    gpio_get_level(SDA_GPIO),
    gpio_get_level(SCL_GPIO));

If both lines read HIGH before doing anything else, the bus is fine. No recovery needed.

Step 2: Scan for devices

for (uint8_t addr = 1; addr < 127; addr++) {
    uint8_t dummy;
    if (i2c_master_transmit(dev, &dummy, 0, -1) == ESP_OK) {
        ESP_LOGI(TAG, "Found device at 0x%02x", addr);
    }
}

If the scan finds your device at 0x68, I2C is working. Everything else is application logic.

Step 3: Only add complexity if needed

Start with the simplest implementation. ESP-IDF’s I2C driver handles clock stretching, bus arbitration, and most edge cases internally. Don’t write custom recovery code unless you have verified the bus is actually broken.

The Documentation Problem

The real lesson here isn’t about I2C or GPIOs. It’s about documentation trust.

When documentation claims a hardware problem exists, validate it before writing workarounds. A simple continuity test with a multimeter would have shown there’s no INT wire on GPIO 13. An I2C scan would have shown the MPU responds just fine.

I trusted the documentation because it was detailed and specific. It described the exact failure mode and even explained the electrical reason why it failed. It sounded authoritative.

But authority without validation is just confident wrongness.

Now the documentation has been rewritten to describe the actual hardware. No INT connection. No GPIO conflict. Just a standard 4-wire I2C bus that works exactly like it should.

Key Takeaways

Validate assumptions before coding workarounds. A simple test beats a detailed explanation.

Start with the simplest implementation. ESP-IDF’s I2C driver works fine for 99% of use cases.

Complex workarounds can create new problems. The bit-bang recovery code was more harmful than helpful.

Documentation can be wrong, especially for hobby hardware. Trust your measurements, not your notes.

When debugging, remove complexity first. Stripping to basics immediately revealed the MPU was functional.

The WHO_AM_I register reading 0x70 instead of 0x71 suggests this is a clone chip, but it works perfectly with standard I2C. Clone or not, it doesn’t need special handling.

References