If you are searching for how to code Raspberry Pi hardware in 2026, the immediate answer is to use Python 3.11+ with the gpiozero and adafruit-blinka libraries, targeting the Raspberry Pi 5 (4GB variant). The Pi 5’s RP1 southbridge chip changed the underlying GPIO routing, rendering the legacy RPi.GPIO library obsolete. Modern hardware control relies on the Linux character device interface, which gpiozero and Blinka abstract beautifully.

This guide walks through a practical, bench-tested project: reading a BME280 environmental sensor over I2C and driving a 5V PWM cooling fan based on the temperature threshold. We will cover the exact parts, the pinout, the complete Python script, and the specific I2C errors that halt builds.

The Decision Path: Which Pi and Language for Hardware Control?

Before writing a single line of code, you must match your software stack to your hardware timing requirements. The Raspberry Pi is a microprocessor running a non-real-time OS (Linux); it is not a microcontroller. Use this decision matrix to lock in your stack.

Project Requirement Recommended Stack Why This Wins
Standard GPIO, I2C, SPI, PWM (Polling > 10ms) Python 3 + gpiozero / Blinka Cleanest syntax, massive community support, native Pi 5 RP1 compatibility.
High-speed bitbanging, sub-microsecond pulse timing C/C++ + pigpio / lgpio Bypasses Python GIL; uses DMA for precise waveform generation.
Hard real-time motor control or sub-1ms sensor polling Do not use Raspberry Pi. Use an ESP32-S3 or Arduino Nano. Linux kernel interrupts will ruin your timing.
Concrete Pick: For 95% of maker projects—including environmental monitoring and thermal management—choose Python 3 with gpiozero. It handles the Pi 5’s RP1 chip transparently and includes built-in fallback behaviors if a sensor drops offline.

Parts List and Pin Mapping

Do not attempt to drive a fan directly from a Raspberry Pi GPIO pin. The Pi 5 GPIO pins can safely source only about 16mA per pin (with a total bank limit). A standard 5V fan draws 100mA to 300mA. We use a logic-level N-Channel MOSFET to switch the fan power, controlled by the Pi’s 3.3V PWM signal.

Bill of Materials (BOM)

  • Board: Raspberry Pi 5 (4GB variant) — Target board for this code.
  • Sensor: BME280 I2C Breakout (Adafruit 2652 or generic 3.3V variant) — Ensure it has 4.7kΩ pull-up resistors on SDA/SCL.
  • Fan: Noctua NF-A4x10 5V PWM (4-pin) — Standard 25kHz PWM PC fan.
  • Switching: IRLZ44N N-Channel MOSFET — Must be "logic-level" (fully turns on at 3.3V Vgs).
  • Protection: 1N4007 Flyback Diode — Prevents inductive voltage spikes from the fan motor from frying the MOSFET.
  • Wiring: 22 AWG solid core hookup wire, breadboard, 10kΩ pull-down resistor (for MOSFET gate).

Pin Mapping Table

Component Pin Raspberry Pi 5 Pin (Physical / BCM) Function
BME280 VCC Pin 1 (3.3V) Sensor Power
BME280 GND Pin 6 (GND) Common Ground
BME280 SCL Pin 5 (GPIO 3 / SCL) I2C Clock
BME280 SDA Pin 3 (GPIO 2 / SDA) I2C Data
MOSFET Gate Pin 12 (GPIO 18 / PWM0) Hardware PWM Control
Fan +5V (Red) Pin 2 or 4 (5V Rail) Fan Power
Fan PWM (Blue) Pin 12 (GPIO 18 via MOSFET) Fan Speed Signal

Step-by-Step: Wiring and Environment Setup

  1. Enable I2C: Boot your Pi 5, open the terminal, and run sudo raspi-config. Navigate to Interface Options > I2C and enable it. Reboot.
  2. Install Dependencies: Install the required Python libraries and I2C tools.
    sudo apt update
    sudo apt install python3-pip i2c-tools python3-venv
    mkdir ~/pi-fan-control && cd ~/pi-fan-control
    python3 -m venv venv
    source venv/bin/activate
    pip install gpiozero adafruit-circuitpython-bme280 lgpio
  3. Verify I2C Bus: Run i2cdetect -y 1. You should see 76 or 77 in the grid. If the grid is empty, check your wiring before proceeding to code.
  4. Wire the MOSFET: Connect the Pi’s GPIO 18 to the MOSFET Gate (with a 10kΩ resistor to GND to keep it off during boot). Connect the MOSFET Drain to the Fan’s PWM wire (Blue). Connect the Fan’s GND (Black) to the Pi’s GND. Place the 1N4007 diode across the fan’s +5V and GND pins (stripe facing +5V) to absorb flyback voltage.

The Complete Python Code

This script initializes the BME280 over I2C, reads the temperature every 2 seconds, and scales the PWM duty cycle to the fan. We use 25,000 Hz (25kHz) for the PWM frequency; this is the Intel 4-wire fan specification and prevents the fan motor from emitting an audible high-pitched whine.

import time
import board
import adafruit_bme280
from gpiozero import PWMOutputDevice

# --- PIN & CONFIGURATION DEFINITIONS ---
# GPIO 18 is hardware PWM0 on the Pi 5 40-pin header
FAN_PIN = 18
PWM_FREQ = 25000  # 25kHz standard for 4-pin PC fans

# Temperature thresholds (Celsius)
TEMP_MIN = 35.0   # Below this, fan is off (0% duty)
TEMP_MAX = 55.0   # Above this, fan is max (100% duty)

def main():
    # Initialize PWM Fan (active_high=False depending on MOSFET logic, 
    # usually True for N-Channel low-side switch)
    fan = PWMOutputDevice(FAN_PIN, frequency=PWM_FREQ, initial_value=0)
    
    # Initialize I2C Sensor
    try:
        i2c = board.I2C()
        # Default address is 0x77. Adafruit breakouts often use 0x77.
        # Generic clones often use 0x76. Adjust if i2cdetect shows 76.
        bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x77)
        bme280.sea_level_pressure_hpa = 1013.25
        print("BME280 sensor initialized successfully.")
    except ValueError as e:
        print(f"Sensor Init Failed: {e}")
        print("Check 'i2cdetect -y 1' and verify I2C address.")
        return
    except OSError as e:
        # Catches the kernel-level I2C bus failure
        print(f"I2C Bus Error: {e}")
        return

    print(f"Starting thermal control loop. Min: {TEMP_MIN}C, Max: {TEMP_MAX}C")
    
    try:
        while True:
            temp_c = bme280.temperature
            
            # Calculate duty cycle (0.0 to 1.0)
            if temp_c <= TEMP_MIN:
                duty = 0.0
            elif temp_c >= TEMP_MAX:
                duty = 1.0
            else:
                # Linear interpolation between min and max
                duty = (temp_c - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)
            
            # Apply to fan
            fan.value = duty
            
            print(f"Temp: {temp_c:.1f}°C | Humidity: {bme280.relative_humidity:.1f}% | Fan Duty: {duty*100:.0f}%")
            
            time.sleep(2.0)
            
    except KeyboardInterrupt:
        print("\nLoop interrupted by user. Spinning down fan.")
    except OSError as e:
        # Catches mid-run I2C disconnects
        print(f"\nRuntime I2C Error: {e}. Sensor disconnected?")
    finally:
        fan.off()
        fan.close()
        print("GPIO cleaned up. Exiting.")

if __name__ == "__main__":
    main()

Debugging: "Remote I/O error" and First Three Checks

When working with I2C on the Raspberry Pi 5, the most common build-halting error is the kernel-level I2C failure. If your script crashes with the following exact string:

OSError: [Errno 121] Remote I/O error

This means the Linux I2C subsystem sent a clock pulse, but the sensor failed to pull the SDA line low to acknowledge (ACK) the address. The Pi 5’s RP1 chip is strict about I2C timing; it will not silently ignore missing ACKs like older bit-banged libraries did.

The First Three Things to Check When It Fails:

  1. Verify the Bus and Address (Software): Run i2cdetect -y 1 in the terminal. If you see -- across the whole grid, the Pi cannot see the bus. If you see 76 but your code requests 0x77, change the address= parameter in the Python script.
  2. Check Pull-Up Resistors (Hardware): The I2C protocol requires pull-up resistors on SDA and SCL. The Pi 5 has internal 50kΩ pull-ups, but these are too weak for reliable I2C at 400kHz. Your BME280 breakout board must have 4.7kΩ or 10kΩ surface-mount pull-up resistors. If you are using a bare BME280 chip on a custom PCB, add them.
  3. Multimeter Continuity Test (Physical): Power down the Pi. Set your multimeter to continuity mode. Probe from the Pi’s Pin 3 (SDA) to the BME280 SDA pad. Dupont wires frequently fail internally while looking intact. If you read > 1 ohm, replace the wire.
Callout Tip: If you accidentally wired the BME280 VCC to 5V (Pin 2) instead of 3.3V (Pin 1), the sensor’s internal voltage regulator may have overheated and permanently fried the I2C transceiver. It will draw excessive current and throw the [Errno 121] error. Always verify breakout board voltage ratings before applying power.

Extending and Simplifying the Build

Once the baseline thermal loop is stable, you have two distinct paths depending on your end goal: expanding the system’s intelligence, or reducing its physical footprint.

How to Extend: Add MQTT for Home Assistant

To integrate this sensor data into a smart home dashboard, add the paho-mqtt library to your virtual environment. Inside the while True: loop, format the sensor data as a JSON payload and publish it to an MQTT broker:

import json
import paho.mqtt.client as mqtt

client = mqtt.Client("Pi5Thermal")
client.connect("192.168.1.100", 1883, 60)

# Inside your loop:
payload = json.dumps({"temp": temp_c, "humidity": bme280.relative_humidity, "fan_duty": duty})
client.publish("homeassistant/sensor/pi5_thermal/state", payload)

This allows Home Assistant to auto-discover the Pi 5 as a native temperature entity without polling a REST API.

How to Simplify: Ditch the Breadboard

If breadboarding the MOSFET and flyback diode feels like overkill for a simple enclosure fan, simplify the hardware by purchasing the official Raspberry Pi 5 Active Cooler (~$5). It plugs directly into the Pi 5’s dedicated JST-SH fan header and the PWM mounting holes. You can then strip the gpiozero PWM code from the script entirely, and instead rely on the Pi 5’s built-in firmware daemon (cooling_fan) which automatically reads the SoC temperature and manages the fan via the /boot/firmware/config.txt file. However, if you need to cool an external component (like a custom LED driver or battery bank) based on ambient room temperature rather than the Pi's CPU temperature, the BME280 + MOSFET method detailed above remains the correct approach.

For more details on the Pi 5 hardware specifications, refer to the official Raspberry Pi documentation. For deeper dives into Python GPIO abstractions, the gpiozero documentation provides exhaustive API references for the RP1 chip.