If you are asking how do you program a raspberry pi for physical hardware control, the direct answer is: you use Python 3 interacting with the Linux character device GPIO interface (via the gpiozero library) and the /dev/i2c-1 bus for sensor communication. Unlike microcontrollers such as the Arduino or ESP32, the Raspberry Pi runs a full operating system. This means your code must handle OS-level permissions, bus contention, and the recent deprecation of legacy GPIO libraries in Raspberry Pi OS Bookworm.

In this guide, we will build a practical environmental logger that reads a BME280 sensor over I2C and triggers a status LED via a hardware button interrupt. We will cover the exact wiring, the production-ready Python code, and the specific error strings you will encounter when things go wrong.

Project Spec Sheet & Parts List

This build targets the Raspberry Pi 5 (4GB or 8GB variant) running Raspberry Pi OS (Bookworm 64-bit). The Pi 5 introduced a new RP1 southbridge chip, which fundamentally changed how GPIO is handled at the kernel level. Legacy libraries like RPi.GPIO are officially deprecated and will throw errors on this board.

Difficulty Rating: 2/5 (Intermediate Beginner)
Estimated Time: 45 minutes
Estimated Cost: $75 - $95 (depending on board variant and existing peripherals)

Required Components

  • Board: Raspberry Pi 5 (4GB or 8GB) with active cooler and 27W USB-C PD power supply.
  • Sensor: Adafruit BME280 I2C Breakout (Product ID: 2652) or equivalent 3.3V logic BME280 module.
  • Indicator: Standard 5mm LED (any color) with a 330Ω through-hole resistor.
  • Input: Momentary tactile pushbutton switch (6x6mm).
  • Wiring: Female-to-female and male-to-female jumper wires (24 AWG silicone preferred for flexibility).
  • Software: Raspberry Pi OS (Bookworm) Desktop or Lite, Python 3.11+.

Pin Mapping & Hardware Wiring

The Raspberry Pi 5 strictly uses 3.3V logic on its GPIO and I2C pins. Feeding 5V into the SDA or SCL lines will permanently damage the RP1 chip. Always verify your sensor breakout has a 3.3V voltage regulator or is natively 3.3V before connecting power.

Pi 5 Physical Pin BCM / GPIO Function Connected To
Pin 1 3V3 Power VCC (3.3V) BME280 VIN
Pin 3 GPIO 2 I2C SDA BME280 SDA
Pin 5 GPIO 3 I2C SCL BME280 SCL
Pin 6 GND Ground BME280 GND
Pin 12 GPIO 18 Digital Output 330Ω Resistor → LED Anode
Pin 14 GND Ground LED Cathode & Button Pin 1
Pin 16 GPIO 23 Digital Input (Pull-up) Button Pin 2
⚠️ Hardware Warning: The Raspberry Pi 5 I2C bus has 1.8kΩ pull-up resistors on the board. If your BME280 breakout board also has onboard pull-ups, the parallel resistance may drop too low, causing signal integrity issues at higher clock speeds. If you experience I2C timeouts, disable the pull-ups on the sensor breakout by cutting the jumper pad on the back of the module.

The Code: I2C Polling with GPIO Interrupts

Before writing the code, install the required Python packages. We use gpiozero for hardware abstraction (which automatically uses the lgpio backend on the Pi 5) and smbus2 alongside pimoroni-bme280 for sensor communication.

sudo apt update
sudo apt install python3-gpiozero python3-lgpio python3-smbus2
pip3 install pimoroni-bme280 --break-system-packages

Save the following script as hardware_logger.py. This code includes explicit pin definitions, interrupt handling, and robust error catching for I2C bus failures.

#!/usr/bin/env python3
"""
Raspberry Pi 5 Hardware Logger
Targets: Raspberry Pi 5 (Bookworm OS)
Reads BME280 over I2C, toggles LED via hardware button interrupt.
"""

import time
import sys
import signal
from gpiozero import LED, Button
from gpiozero.exc import BadPinFactory
import smbus2
from bme280 import BME280

# --- Pin & Bus Definitions ---
LED_PIN = 18        # BCM GPIO 18 (Physical Pin 12)
BUTTON_PIN = 23     # BCM GPIO 23 (Physical Pin 16)
I2C_BUS_ID = 1      # /dev/i2c-1 (Physical Pins 3 & 5)
BME280_I2C_ADDR = 0x76  # Default Adafruit address (check with i2cdetect)

def graceful_exit(signum, frame):
    """Handle Ctrl+C to safely clean up GPIO states."""
    print("\n[INFO] Interrupt received. Cleaning up and exiting.")
    status_led.off()
    sys.exit(0)

# Register signal handler for clean shutdown
signal.signal(signal.SIGINT, graceful_exit)

try:
    # Initialize GPIO devices
    status_led = LED(LED_PIN)
    trigger_button = Button(BUTTON_PIN, pull_up=True, bounce_time=0.05)
    
    # Initialize I2C bus and Sensor
    bus = smbus2.SMBus(I2C_BUS_ID)
    bme_sensor = BME280(i2c_dev=bus, i2c_addr=BME280_I2C_ADDR)
    
    # Warm-up the sensor (first few reads can be stale)
    bme_sensor.get_temperature()
    time.sleep(1.0)

    def handle_button_press():
        """Callback for hardware interrupt on button press."""
        status_led.toggle()
        state = "ON" if status_led.is_lit else "OFF"
        print(f"[INTERRUPT] Button pressed! LED is now {state}.")

    # Bind the interrupt callback
    trigger_button.when_pressed = handle_button_press
    print("[SYSTEM] Hardware initialized. Monitoring sensor and button...")

    # Main polling loop
    while True:
        try:
            temp_c = bme_sensor.get_temperature()
            humidity = bme_sensor.get_humidity()
            pressure = bme_sensor.get_pressure()
            
            print(f"[DATA] Temp: {temp_c:.2f}°C | Humidity: {humidity:.1f}% | Pressure: {pressure:.1f} hPa")
            time.sleep(5.0)
            
        except OSError as e:
            print(f"[ERROR] I2C Communication Failed: {e}")
            print("[ACTION] Check wiring and run 'i2cdetect -y 1' in terminal.")
            time.sleep(5.0) # Prevent log spamming on bus failure

except BadPinFactory as e:
    print(f"[FATAL] GPIO Backend Missing: {e}")
    print("[FIX] Run: sudo apt install python3-lgpio")
    sys.exit(1)
except PermissionError as e:
    print(f"[FATAL] I2C Permission Denied: {e}")
    print("[FIX] Run: sudo usermod -aG i2c $USER and reboot.")
    sys.exit(1)
except FileNotFoundError:
    print(f"[FATAL] I2C Bus {I2C_BUS_ID} not found.")
    print("[FIX] Enable I2C via 'sudo raspi-config' -> Interface Options.")
    sys.exit(1)

Debugging: First Three Things to Check When It Fails

When programming embedded Linux, hardware errors rarely mean your code logic is wrong; they almost always point to OS permissions, bus addressing, or missing kernel modules. If the script crashes, look for these exact error strings.

1. Error: OSError: [Errno 121] Remote I/O error

What it means: The Pi sent an I2C address request, but no device acknowledged it (NACK). The kernel timed out waiting for the sensor to pull the SDA line low.
Ranked Causes & Fixes:

  1. Wrong I2C Address: The BME280 might be at 0x77 instead of 0x76. Run i2cdetect -y 1 in the terminal. If you see 77 in the grid, change BME280_I2C_ADDR in the code.
  2. Missing Pull-up Resistors: If using a raw BME280 chip instead of a breakout board, you must add 4.7kΩ pull-up resistors to 3.3V on both SDA and SCL lines.
  3. Loose Jumper Wire: Dupont wires degrade quickly. Swap the SCL/SDA wires and ensure they are fully seated on the Pi 5 header.

2. Error: gpiozero.exc.BadPinFactory: Unable to load any default pin factory

What it means: gpiozero cannot find a compatible backend to talk to the Pi 5's RP1 chip. The old RPi.GPIO factory is no longer supported on Bookworm.
Ranked Causes & Fixes:

  1. Missing lgpio package: This is the most common issue on fresh Pi OS installs. Fix it by running sudo apt install python3-lgpio.
  2. Virtual Environment Isolation: If you are running inside a Python venv, the system-installed lgpio bindings won't be visible. You must either install lgpio inside the venv via pip, or use the --system-site-packages flag when creating the virtual environment.

3. Error: PermissionError: [Errno 13] Permission denied: '/dev/i2c-1'

What it means: Your current Linux user does not belong to the i2c user group, so the kernel blocks access to the hardware bus.
Ranked Causes & Fixes:

  1. User not in group: Run sudo usermod -aG i2c $USER, then reboot or log out and back in for the group change to take effect.
  2. Running via cron/sudo mismatch: If you set this script to run via sudo crontab, it runs as root (which bypasses the error), but if you run it as a standard user without the group assignment, it fails. Standardize on user-level execution with proper group permissions.

Extending or Simplifying the Build

Depending on your end goal, you may want to strip this project down to its bare essentials or scale it up into a distributed IoT network.

To Simplify (Kiosk / Display Mode)

If you just want to log data to a local CSV file without hardware interrupts, remove the gpiozero button initialization entirely. Replace the handle_button_press callback with a simple file-append operation inside the while True loop using Python's built-in csv module. This eliminates the need for the tactile switch and resistor, reducing the BOM cost and wiring complexity.

To Extend (Home Assistant Integration)

To push this data to a smart home dashboard, integrate the paho-mqtt library. Inside the while True loop, format the sensor readings into a JSON payload and publish it to an MQTT broker (like Mosquitto) running on your network. Home Assistant can then auto-discover the Pi as an MQTT sensor entity, allowing you to build dashboards and automate HVAC controls based on the room's real-time humidity and temperature.

FAQ: Programming the Raspberry Pi

How do you program a Raspberry Pi without a monitor or keyboard?

You can program the Pi "headless" by enabling SSH and WiFi before the first boot. When flashing Raspberry Pi OS using the official Raspberry Pi Imager, click the gear icon (or press Ctrl+Shift+X) in the bottom right corner. From there, you can set your WiFi SSID/password, enable SSH (using password or key authentication), and set a custom hostname. Once powered on, connect via your terminal using ssh yourusername@raspberrypi.local and use a CLI text editor like nano or vim, or use VS Code's "Remote - SSH" extension to write code on your main PC while executing it on the Pi.

Can you program a Raspberry Pi in C++ instead of Python?

Yes. While Python is the default for GPIO control due to its readability, C++ is preferred for high-frequency signal processing or when minimizing CPU overhead is critical. To program the Pi 5 in C++, you should use the libgpiod C API directly, or use the pigpio library (which supports C and Python). You will need to compile your code using g++ and link the appropriate libraries (e.g., -lgpiod). Keep in mind that C++ requires manual memory management and explicit file descriptor closing for I2C buses to prevent resource leaks.

Why is RPi.GPIO throwing errors on my Raspberry Pi 5?

The RPi.GPIO library relies on direct memory mapping to the Broadcom SoC's GPIO registers. The Raspberry Pi 5 uses a completely different architecture with the RP1 southbridge chip, making direct memory mapping impossible. Furthermore, Raspberry Pi OS Bookworm shifted to a strict Linux character device model for GPIO access. As a result, RPi.GPIO is officially deprecated. You must migrate your code to gpiozero (which uses the lgpio backend on Pi 5) or use libgpiod directly.

How do you auto-start a Python script on boot in Raspberry Pi OS?

The most robust, modern method is to create a systemd service. Do not use rc.local or .bashrc, as they are deprecated or only trigger on interactive login. Create a file at /etc/systemd/system/hardware-logger.service with the [Unit], [Service] (pointing to your Python executable and script path), and [Install] sections. Then run sudo systemctl enable hardware-logger.service and sudo systemctl start hardware-logger.service. This ensures your script starts on boot, restarts automatically if it crashes, and logs its output to the system journal (viewable via journalctl -u hardware-logger), which is invaluable for debugging embedded deployments.