The Verdict: Best Python Stack for Raspberry Pi Hardware Control
When writing Python for Raspberry Pi hardware projects, the ecosystem has shifted significantly with the introduction of the Pi 5 and the RP1 chip. The legacy RPi.GPIO library is effectively deprecated for modern OS releases, and running background daemons for basic pin toggling is unnecessary overhead.
The direct answer: For 95% of sensor and actuator projects, use gpiozero for digital pins and smbus2 for I2C communication. This combination requires no background daemons, works natively on both Pi 4 and Pi 5 (with updated firmware), and cleanly separates GPIO logic from bus protocols.
Library Decision Tree
| Criteria | RPi.GPIO | pigpio | gpiozero + smbus2 |
|---|---|---|---|
| Daemon Required? | No | Yes (pigpiod) | No |
| Pi 5 Native Support | No (Requires rpi-lgpio) | Yes | Yes |
| I2C / SPI Support | No (GPIO only) | Yes (Complex API) | Yes (Via smbus2/spidev) |
| Learning Curve | Low | High | Low |
Default Pick: Install gpiozero and smbus2. Only pivot to pigpio if you specifically need hardware-timed PWM on more than two channels simultaneously for motor control.
Parts List & Spec Sheet
This build targets the Raspberry Pi 4 Model B (4GB) running Raspberry Pi OS (Bookworm or later, 64-bit). The Pi 4 remains the most stable baseline for I2C library compatibility, though this exact hardware list is fully forward-compatible with the Pi 5.
- Microcontroller: Raspberry Pi 4 Model B (4GB RAM) — ~$55
- Environmental Sensor: BME280 I2C Temperature/Humidity/Pressure (Adafruit 2652 or generic 3.3V breakout) — ~$12
- Display: SSD1306 128x64 I2C OLED Display (Monochrome, 3.3V/5V tolerant) — ~$10
- Actuator: 5V Relay Module (Opto-isolated, active LOW trigger) — ~$6
- Wiring: Female-to-Female Dupont Jumper Wires (20cm, 28 AWG) — ~$5
- Storage: 32GB MicroSD Card (SanDisk Extreme A2) — ~$12
- Power: Official Raspberry Pi 27W USB-C Power Supply (5.1V / 5A) — ~$12
Wiring & Pin Mapping
The Raspberry Pi uses BCM (Broadcom) numbering for software pin definitions, but the physical header uses pin numbers. Always wire with the Pi powered off. The I2C1 bus on the Pi includes onboard 1.8kΩ pull-up resistors to 3.3V, so standard breakout boards without their own pull-ups will work fine.
| Component | Component Pin | Pi Physical Pin | Pi BCM GPIO | Notes |
|---|---|---|---|---|
| BME280 | VIN / VCC | 1 | 3.3V Power | Do NOT use 5V; BME280 is strictly 3.3V |
| BME280 | GND | 6 | Ground | Common ground required |
| BME280 | SCK / SCL | 5 | BCM 3 (SCL1) | I2C Clock |
| BME280 | SDI / SDA | 3 | BCM 2 (SDA1) | I2C Data |
| SSD1306 OLED | VCC | 17 | 3.3V Power | Can tolerate 5V, but 3.3V is safer |
| SSD1306 OLED | GND | 14 | Ground | Common ground required |
| SSD1306 OLED | SCL | 5 | BCM 3 (SCL1) | Shared I2C Clock bus |
| SSD1306 OLED | SDA | 3 | BCM 2 (SDA1) | Shared I2C Data bus |
| 5V Relay | VCC | 2 | 5V Power | Relay coil requires 5V |
| 5V Relay | GND | 9 | Ground | Common ground required |
| 5V Relay | IN (Signal) | 11 | BCM 17 | Active LOW trigger |
The Build: Python Code for BME280 and SSD1306
Before running the code, enable the I2C interface via sudo raspi-config (Interface Options -> I2C -> Enable) and install the required Python packages:
sudo apt update
sudo apt install python3-smbus i2c-tools python3-pil
pip3 install gpiozero luma.oled RPi.bme280
The following script reads the climate data, renders it to the OLED, and toggles the relay if the temperature exceeds 24.5°C. It includes explicit pin definitions and robust error handling for I2C bus drops.
import time
import smbus2
import bme280
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306
from gpiozero import OutputDevice
import signal
import sys
# --- PIN & ADDRESS DEFINITIONS ---
RELAY_PIN = 17 # BCM 17, Physical Pin 11
I2C_PORT = 1 # Hardware I2C bus 1
BME280_ADDR = 0x76 # Default for Adafruit/generic breakouts (0x77 if SDO is high)
OLED_ADDR = 0x3C # Standard for 128x64 SSD1306
TEMP_THRESHOLD = 24.5 # Celsius
# --- HARDWARE INITIALIZATION ---
try:
# GPIO Setup (Active low means pin goes LOW to trigger the relay)
relay = OutputDevice(RELAY_PIN, active_high=False, initial_value=False)
# I2C Bus Setup
bus = smbus2.SMBus(I2C_PORT)
# Load BME280 calibration data from the sensor's internal registers
calibration_params = bme280.load_calibration_params(bus, BME280_ADDR)
# OLED Setup
serial = i2c(port=I2C_PORT, address=OLED_ADDR)
oled = ssd1306(serial, width=128, height=64)
oled.clear()
except OSError as e:
print(f'Hardware Init Failed: {e}')
sys.exit(1)
def safe_shutdown(signum, frame):
print('\nShutdown signal received. Cleaning up...')
relay.off()
oled.clear()
oled.show_message('Shutting down...')
sys.exit(0)
signal.signal(signal.SIGINT, safe_shutdown)
signal.signal(signal.SIGTERM, safe_shutdown)
# --- MAIN LOOP ---
try:
while True:
# Read sensor data
data = bme280.sample(bus, BME280_ADDR, calibration_params)
temp = data.temperature
humidity = data.humidity
pressure = data.pressure
# Determine relay state
if temp > TEMP_THRESHOLD:
relay.on()
fan_state = 'ON'
else:
relay.off()
fan_state = 'OFF'
# Render to OLED
with canvas(oled) as draw:
draw.text((0, 0), f'Temp: {temp:.1f} C', fill='white')
draw.text((0, 16), f'Hum: {humidity:.1f} %', fill='white')
draw.text((0, 32), f'Press:{pressure:.0f} hPa', fill='white')
draw.text((0, 48), f'Fan: {fan_state}', fill='white')
time.sleep(2.0)
except OSError as e:
# Catches I2C bus disconnects or NACK errors mid-loop
print(f'I2C Bus Error during runtime: {e}')
relay.off()
except Exception as e:
print(f'Unexpected error: {e}')
finally:
relay.off()
oled.clear()
Debugging 'OSError: [Errno 121] Remote I/O error'
If you run the script and immediately hit OSError: [Errno 121] Remote I/O error, your Python code is fine, but the Pi cannot communicate with the I2C slave device. This is the most common failure mode in Raspberry Pi sensor projects.
The First Three Things to Check
- Run the bus scan: Execute
i2cdetect -y 1in the terminal. If the output is a grid of dashes with no hex addresses (like3cor76), the Pi physically cannot see the sensor. If you seeUU, the kernel has already claimed the device (common with RTC modules, rare with BME280s). - Verify physical ground continuity: Use a multimeter in continuity mode. Check between the GND pin on the BME280 breakout and the metal shield of the Pi's USB port. A missing common ground causes the I2C data line to float, resulting in Errno 121.
- Confirm I2C is enabled in the bootloader config: On modern Raspberry Pi OS (Bookworm+), the config file moved. Check
/boot/firmware/config.txt(not/boot/config.txt) and ensure the linedtparam=i2c_arm=onis present and uncommented. Reboot after changing.
Ranked Causes for Persistent I2C Failures
| Rank | Cause | Fix / Measurement |
|---|---|---|
| 1 | I2C disabled in OS config | Enable via raspi-config or edit config.txt. |
| 2 | Wrong I2C address in code | BME280 is usually 0x76 or 0x77. Check the i2cdetect output and update BME280_ADDR. |
| 3 | Missing pull-up resistors | Measure SDA/SCL lines to 3.3V. Should read ~3.3V at rest. If floating near 0V, add 4.7kΩ pull-ups. |
| 4 | 5V logic injected into 3.3V bus | Never connect a 5V Arduino sensor directly to the Pi I2C pins without a logic level shifter (e.g., BSS138). The Pi GPIO will clamp and fail. |
| 5 | Wire capacitance too high | If Dupont wires exceed 30cm, signal edges degrade. Shorten wires or drop I2C baud rate in config.txt using dtparam=i2c_arm_baudrate=10000. |
For deeper hardware diagnostics, reference the official Raspberry Pi I2C Configuration Guide and the GPIO Zero documentation for pin state verification.
Scaling the Build: Extend or Simplify
Once the baseline climate controller is running, you will likely need to adapt it for production or strip it down for a headless deployment. Here is the exact path forward.
How to Extend (Adding Network & Automation)
- Add MQTT Telemetry: Install
paho-mqtt. Inside thewhileloop, format the sensor data as a JSON payload and publish it to a local Mosquitto broker topic likehome/environment/livingroom. This integrates the Pi directly into Home Assistant without needing local polling. - Add a Second I2C Bus: The Pi 4 only exposes one hardware I2C bus (I2C1) on the main header. If you need to add a second BME280 for an outdoor reading, enable software I2C by adding
dtparam=i2c_vc=onto your config, or define a software I2C bus on arbitrary GPIO pins using thei2c-gpiodevice tree overlay.
How to Simplify (Headless & Low Power)
- Drop the OLED: OLED displays draw roughly 20mA and suffer from burn-in if left on 24/7. Remove the
luma.oleddependencies entirely. Replace the display logic with a simple CSV append operation or an SQLite insert to log data locally. - Switch to a Microcontroller: If you do not need a full Linux OS, WiFi routing, or a local web server, a Raspberry Pi is overkill for a simple thermostat. Migrate this exact logic to an ESP32 running MicroPython. An ESP32 draws a fraction of the power (allowing for battery/solar operation) and boots in milliseconds compared to the Pi's 15-second Linux boot sequence.
Final Recommendation: If your project requires local data logging, a web dashboard, or complex edge computing (like running a local LLM or computer vision alongside the sensor), keep the Raspberry Pi 4 and stick to the gpiozero + smbus2 stack. If the sole purpose is reading a sensor and toggling a relay, migrate to an ESP32 to eliminate SD card corruption risks and reduce idle power draw from 2.5W to under 0.5W.






