M5Stack StickS3 Grove I²C issue with SCD41 (works on AtomS3U)

I’m trying to use an SCD41 (CO2L unit) over the built-in Grove port on an M5Stack StickS3 with ESPHome.

Configuration:

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf

i2c:
  sda: GPIO9
  scl: GPIO10
  scan: true

sensor:
  - platform: scd4x
    co2:
      name: "CO2"
    temperature:
      name: "Temperature"
    humidity:
      name: "Humidity"

Compile + OTA upload succeed without issues.

However, at runtime I consistently get:

[I][i2c.idf:106]: Recovery: failed, SCL is held low on the bus
[I][i2c.idf:115]: Found no devices
[W][scd4x:116]: Communication failed
[E][component:224]: scd4x.sensor is marked FAILED

What I already verified:

  • Different Grove cable
  • Lower I²C frequency
  • Sensor + cable work perfectly on an AtomS3U using nearly identical YAML
  • Sensor is detected correctly at 0x62 on the AtomS3U

This makes me suspect the issue is specific to the StickS3 Grove/power implementation rather than the SCD41 itself.

I found references suggesting the StickS3 uses a PMIC/co-processor to control external Grove power output. Could it be that the Grove port is not actually powering the SCD41 under ESPHome unless some additional initialization is done?

Has anyone successfully used I²C Grove sensors on the StickS3 with ESPHome?

I don't have your device but if you ever opened the M5 product page you should have noticed:

How to set Grove 5V as output is not so clear though and schematics suck, but try to write gpio4 high using switch or on_boot automation..:

switch:
  - platform: gpio
    pin: GPIO4
    id: grove_power
    restore_mode: ALWAYS_ON

Thanks for the suggestion! It definitely makes sense to think of a GPIO switch, especially since M5Stack used a similar approach on some of their older boards (like the original StickC or Atom) where a dedicated GPIO or an AXP PMIC pin handled the boost circuit.

However, for the M5StickS3 (K150), this unfortunately won't work. I actually dug into the official schematics (K150_Stick_S3_PRJ_V0.6) and the datasheets for the internal components, and it turns out the hardware architecture is completely different:

  1. GPIO4 is mapped elsewhere: On the StickS3, ESP32's GPIO4 is routed directly to the EXT_GPIO4 pin on the HAT2 connector on top of the device. It has no physical trace to the Grove power circuit. (Also, on the PMIC chip itself, there is a pin named IO4, but that's internally multiplexed for battery ADC enabling, not the external 5V).
  2. The actual power path: The 5V for the Grove port (GROVE_5V) is generated by an AW35122 boost-converter chip. This converter is enabled by a signal called EXT_5V_EN, which is driven entirely by the internal PMIC/co-processor (the PY32L020 chip).

The schematics definitely suck and are super confusing with all the overlapping naming conventions between the ESP32 and the PMIC co-processor, but it's good to have this ruled out!

Where I'm currently stuck: Since the official documentation states the boost converter is controlled via the PMIC, I tried to bypass the M5Unified library entirely and manage it directly within ESPHome. I defined two separate I2C buses (an internal one on GPIO11/12 and the external Grove one on GPIO9/10).

During on_boot, I use a high-priority lambda to write directly to the PMIC (address 0x34, register 0x10, data 0x01) to enable the 5V output, combined with a delayed setup_priority (using negative float values like -100.0) to give the SCD41 sensor time to boot up before the external bus starts scanning.

While this successfully fixes the dreaded SCL is held low bus recovery errors on boot, the SCD41 sensor itself still returns scd4x.sensor is marked FAILED: unspecified. The ESP32 clears the lines, but the sensor never actually responds. It feels like the PMIC register configuration isn't sticking or behaves differently under ESPHome's framework compared to the official Arduino/UIFlow environment.

Unless someone has found the magic register combination for the StickS3 PMIC in pure ESPHome, it looks like a hardware bypass (pulling 5V from the 5V HAT pin on top) is the only way forward.

I suggested to try gpio switch from my thin memory about some m5 device power control.
You are right about the power path, but I'm not sure about your approach to enable.

I might be on wrong path here but M5 docs give:
M5.Power.setExtOutput(true); // EXT_5V OUTPUT

Power_Class.ccp has:

void Power_Class::setExtOutput(bool enable, ext_port_mask_t port_mask)
...
...
    case board_t::board_M5StickS3:
    case board_t::board_M5StopWatch:
    case board_t::board_M5PaperColor:
      if (_pmic == pmic_t::pmic_m5pm1)
      {
        // Control 5V output: register 0x06 bit 3 (1=enable, 0=disable)
        uint8_t reg_val = M5.In_I2C.readRegister8(m5pm1_i2c_addr, 0x06, i2c_freq);
        if (enable) {
          reg_val |= 0x08;  // Set bit 3
        } else {
          reg_val &= ~0x08; // Clear bit 3
        }
        M5.In_I2C.writeRegister8(m5pm1_i2c_addr, 0x06, reg_val, i2c_freq);
      }
      break;

So it looks like you have to read current value, modify one bit and write it back.

Wow, amazing catch! You found the missing link in Power_Class.cpp.

I was completely blindsided by some outdated M5 documentation that pointed towards register 0x10 as a standalone switch. Your snippet makes it clear: for the StickS3, it's actually register 0x06``, and we specifically need to flip bit 3 (0x08`) using a read-modify-write approach to avoid messing up other critical PMIC functionalities.

Looking deeper into the M5PM1 datasheet and schematics, it turns out it’s a two-stage process. Register 0x06 bit 3 enables the central DCDC_5V boost circuit, while register 0x10 controls the actual GPIO output configuration of the PMIC to route that 5V out to the Grove port (EXT_5V_EN).

I translated this exact logic into an ESPHome on_boot lambda using the native IDFI2CBus methods under the esp-idf framework, tackling both registers:

`YAML on_boot:
priority: 800.0 # Run very early in the boot process
then:
- lambda: |-
auto* internal_bus = new esphome::i2c::IDFI2CBus();
internal_bus->set_sda_pin(11);
internal_bus->set_scl_pin(12);
internal_bus->set_frequency(100000.0);
internal_bus->setup();

      // --- STEP 1: Read-Modify-Write Register 0x06 ---
      uint8_t reg_addr_06 = 0x06;
      uint8_t reg_val_06 = 0;
      
      if (internal_bus->write(0x34, &reg_addr_06, 1) && internal_bus->read(0x34, &reg_val_06, 1)) {
        reg_val_06 |= 0x08; // Set bit 3 high
        uint8_t write_payload_06[2] = {0x06, reg_val_06};
        internal_bus->write(0x34, write_payload_06, 2);
      }
      
      // --- STEP 2: Configure PMIC GPIO1 Output (Register 0x10) ---
      uint8_t write_payload_10[2] = {0x10, 0x03}; // Push-Pull Output HIGH
      internal_bus->write(0x34, write_payload_10, 2);
      
      delete internal_bus;`

The issue I'm facing now: Even though this C++ code compiles fine and completely fixes the dreaded SCL is held low bus recovery errors on boot, the SCD41 sensor still refuses to cooperate. The ESP32 clears the lines, but the sensor never responds on address 0x62.

Worse yet, pulling raw registers on the M5PM1 without the full M5Unified state-machine running in the background seems to cause severe instability during the early boot phase. The ESP32 occasionally panics and crash-loops so fast that ESPHome triggers an automatic safe-mode OTA rollback (Last reset too quick; OTA rollback detected!), wiping out the custom configuration.

It feels like the PY32 PMIC co-processor requires some extra initialization scaffolding (clocks, power domains, or sleep states) to safely sustain the Grove power rail under pure ESPHome.

Any ideas on what I might be missing here? Has anyone managed to successfully get the Grove power rail stable on the StickS3 within ESPHome?

Last time I participated in topic about M5 power paths, author gave up and took power from where it was readily available. I would have done the same, why to spend whole day with complicated circuits, shit documentation and crap schematics if you can just place a jumper wire and get your power from where it's readily available.
M5 devices are interesting, low cost common devices with practical enclosure and connectors readily available via reputable suppliers. But I rarely use them, too complicated and often questionable design. A while ago I used hx711 module from them, I found clearly the circuit violates the MCU it was designed for (esp32).

I finally got mine working, after a lot of troubleshooting.

  • The display backlight off. Media functions not working. IMU working.
  • Backlight on! DAC and IMU working. Succes!
    WRONG
    Grove port i2c not working.
    Need to setup M5PM1 to enable 5v boost before (grove) i2c initializes
    M5PM1 communicates over i2c :zany_face:
  • Like DriesA, I did a temporary i2c in on_boot lambda.
    Internal i2c, grove i2c and temporary i2c conflicts with 2 bus limit
  • Had to change priority to 1200 to run before ESPHome i2c initializes.
    Still got errors, delete internal_bus does not completely clear i2c lane. Complaints of more than 2 i2c buses
  • Tried to hijack already defined internal i2c and set it up earlier, did not work.
  • Back to temp i2c, then i2c_driver_delete. Did not compile :nerd_face:
    At this point I discovered
    GitHub - ccsmart/M5SS3-esphome: esphome template for M5 StickS3 · GitHub
    But that would still not solve setting 5v boost before i2c initializes
    #include <driver/i2c.h>
    In the M5SS3 custom component m5pm1.h
    It now compiles
    i2c_driver_delete caused bootloop :pensive_face:
  • Mismatch of driver/i2c.h and driver/i2c_master.h
    Been using outdated i2c functions in the lambda this whole time
  • Finally solved with:
on_boot action
esphome:
  on_boot:
    - priority: 1200
      then:
        - lambda: |-
            // 1. Configure the new ESP-IDF v5 Master Bus
            i2c_master_bus_config_t bus_config = {};
            bus_config.i2c_port = -1; // Auto-allocate a free hardware port
            bus_config.sda_io_num = GPIO_NUM_47;
            bus_config.scl_io_num = GPIO_NUM_48;
            bus_config.clk_source = I2C_CLK_SRC_DEFAULT;
            bus_config.glitch_ignore_cnt = 7;
            bus_config.flags.enable_internal_pullup = true;

            i2c_master_bus_handle_t bus_handle;
            if (i2c_new_master_bus(&bus_config, &bus_handle) != ESP_OK) return;

            // 2. Add the PMIC device to the bus
            i2c_device_config_t dev_config = {};
            dev_config.dev_addr_length = I2C_ADDR_BIT_LEN_7;
            dev_config.device_address = 0x6E;
            dev_config.scl_speed_hz = 100000;

            i2c_master_dev_handle_t dev_handle;
            if (i2c_master_bus_add_device(bus_handle, &dev_config, &dev_handle) != ESP_OK) {
              i2c_del_master_bus(bus_handle);
              return;
            }

            // 3. Helper function for Read-Modify-Write using driver_ng
            auto update_reg = [&](uint8_t reg, uint8_t set_bits, uint8_t clear_bits) {
                uint8_t val = 0;
                if (i2c_master_transmit_receive(dev_handle, &reg, 1, &val, 1, 1000) == ESP_OK) {
                    val |= set_bits;
                    val &= ~clear_bits;
                    uint8_t write_buf[2] = {reg, val};
                    i2c_master_transmit(dev_handle, write_buf, 2, 1000);
                }
            };

            // --- STEP 1: Enable 5V Boost (Register 0x06, Bit 3) ---
            update_reg(0x06, 0x08, 0x00);
            
            // --- STEP 2: Configure PMIC GPIO2 for L3B_EN ---
            update_reg(0x10, 0x04, 0x00); // Set Output Mode
            update_reg(0x13, 0x00, 0x04); // Set Push-Pull
            update_reg(0x11, 0x04, 0x00); // Drive HIGH

            // 4. Safely destroy the v5 hardware handle to completely free the port!
            i2c_master_bus_rm_device(dev_handle);
            i2c_del_master_bus(bus_handle);
            
            // Allow 150ms for the Grove sensor and L3B rail to boot and release the SCL lines
            vTaskDelay(pdMS_TO_TICKS(150));

Check my full StockS3 config at