micropython-i2c-hardware-softi2c

I2C Communication in MicroPython: I2C and SoftI2C

  • 5 min

The I2C (Inter-Integrated Circuit) is a synchronous bus that connects devices using two lines.

With only two wires, it allows us to connect displays, sensors, and port expanders to our microcontroller without creating a pin festival.

It is the protocol you will use to connect an OLED display, a BME280 temperature sensor, an accelerometer, or a port expander.

The great thing about I2C is that each device has a unique address (like a house number on a street). The microcontroller (master) says: “Hey, device number 0x3C!”, and only that device responds, ignoring the others.

The two wires: SDA and SCL

In I2C we need to connect:

  1. SDA (Serial Data): The line over which data travels.
  2. SCL (Serial Clock): The clock signal that sets the pace (synchronous).
  3. GND: Always, always, common grounds.

Pull-Up Resistors The I2C bus uses “open-collector” logic. This means it needs resistors (typically 4.7kΩ) connecting SDA and SCL to 3.3V to function. Fortunately, most commercial modules and sensors already have them built-in. But if you use bare chips, don’t forget them!

Hardware I2C and SoftI2C

In MicroPython (and electronics in general), there are two ways to control this bus. Understanding the difference is crucial:

machine.I2C hardware

It uses the dedicated physical circuits inside the chip.

  • Pros: It is very fast (up to 400kHz or more), does not consume CPU, and is very stable.
  • Cons: You are limited to using specific pins or specific pin matrices of the microcontroller.

machine.SoftI2C software

The processor “simulates” the I2C signal by manually turning GPIO pins on and off through software.

  • Pros: You can use any pair of GPIO pins! It is extremely flexible.
  • Cons: It is slower and consumes processor resources.

Which one should I choose? On ESP32 and Raspberry Pi Pico, the SoftI2C implementation is excellent and sufficient for 90% of cases (reading sensors, displays). If you need maximum speed, look for the hardware pins in the datasheet and use I2C.

Initialization and bus scanning

The first thing we should always do when connecting an I2C device is to perform a scan. This confirms the wiring is correct and tells us the address of our device.

We will use SoftI2C for its flexibility.

from machine import Pin, SoftI2C
import time

# Configuration for ESP32 (you can change the pins)
# sda=Pin(21), scl=Pin(22) are typical on ESP32 boards
i2c = SoftI2C(scl=Pin(22), sda=Pin(21), freq=100000)

# Configuration for Raspberry Pi Pico
# i2c = SoftI2C(scl=Pin(1), sda=Pin(0), freq=100000)

print("Scanning I2C bus...")
devices = i2c.scan()

if len(devices) == 0:
    print("Nothing found! Check wiring and pull-ups.")
else:
    print("Devices found:", len(devices))
    for device in devices:
        print("Decimal Address:", device, " | Hexadecimal:", hex(device))
Copied!

If you see something like Hexadecimal: 0x3c, congratulations! You have a connection. (By the way, 0x3C is often an OLED display).

Reading and writing registers

This is where I2C differentiates itself from UART. I2C devices work like a shelf full of numbered drawers. These drawers are called Registers.

  • If you want to know the temperature, you have to read the specific “drawer” (register) where the sensor stores that data.
  • If you want to configure the sensitivity, you have to write to the configuration “drawer”.

For this, we use the readfrom_mem and writeto_mem methods (mem comes from memory/register).

Conceptual example: reading a sensor

Let’s imagine a fictional sensor with address 0x50 that stores temperature data in register 0x10.

# Device address (the one we got from scanning)
DEVICE_ADDR = 0x50

# Address of the register we want to read
TEMP_REG = 0x10

# Read 2 bytes from that register (because the data might be large)
data = i2c.readfrom_mem(DEVICE_ADDR, TEMP_REG, 2)

# 'data' is a bytes object, for example b'\x01\x45'
# Now we would have to convert those bytes into a useful number
value = int.from_bytes(data, 'big')
print("Raw sensor value:", value)
Copied!

Writing a configuration

Now we want to change the sensor’s configuration. The datasheet says the control register is 0x20 and writing a 1 activates high-precision mode.

DEVICE_ADDR = 0x50
CTRL_REG = 0x20

# Write a byte with value 1 to register 0x20
# We need to pass the data as bytes, hence the b'\x01'
i2c.writeto_mem(DEVICE_ADDR, CTRL_REG, b'\x01')
Copied!

Basic methods and memory methods

Sometimes you’ll see tutorials using writeto (without _mem). The difference is subtle but important:

  • writeto(addr, data): Sends data “directly” to the device. Useful for OLED displays or simple devices that process data streams.
  • writeto_mem(addr, reg, data): First sends the register address, then the data. This is what 99% of sensors use.

Common errors

  1. OSError: [Errno 19] ENODEV: Means “Error No Device”. The microcontroller has yelled “Hey!” and no one has responded with “ACK!”.
  • Check the wiring (SDA to SDA, SCL to SCL). In I2C, they are NOT crossed!
  • Check if the device is powered.
  • Missing Pull-Up resistors.
  1. Wrong addresses: Many sensors change their address if you connect a pin called ADDR to GND or VCC. Check the datasheet.

I2C is the foundation of modern robotics and home automation. Although at first it can be daunting to look up memory addresses and registers, the good news is that there will almost always be a library (driver) that does this dirty work for you, encapsulating these readfrom_mem calls into easy-to-use functions like sensor.read_temperature().

But now, if that library fails, you know how to fix it!