Project Overview & Difficulty Rating
When building with Raspberry Pi for physical environment control, the jump from a basic script to a reliable, fault-tolerant node requires understanding both the hardware bus and the software stack. This project builds a closed-loop environmental controller: it reads temperature and humidity from a Bosch BME280 sensor via the I2C bus and triggers a 5V relay module to switch an exhaust fan or heater.
Target Board Variant: This guide and code specifically target the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS (Bookworm or later). The Pi 5 introduces the RP1 southbridge chip, which changes how GPIO and I2C are handled at the silicon level compared to the BCM2711 on the Pi 4. We will account for these architectural shifts in the wiring and debugging sections.
Hardware BOM & Pin Mapping
Before cutting wires, verify you have the exact variants listed below. Substituting a generic 'BME280' breakout often leads to I2C address conflicts or missing pull-up resistors. The Adafruit variant includes the necessary 4.7kΩ I2C pull-ups and a 3.3V voltage regulator, which is critical since the Pi 5 GPIO pins are strictly 3.3V tolerant.
| Component | Exact Model / Variant | Approx Price (2026) | Key Specification |
|---|---|---|---|
| Compute Board | Raspberry Pi 5 (8GB RAM) | $80.00 | RP1 Southbridge, 3.3V GPIO logic |
| Env Sensor | Adafruit BME280 I2C/SPI Breakout (PID 2652) | $19.95 | I2C Addr: 0x77 (default), 3.3V/5V tolerant |
| Relay Module | Songle SRD-05VDC-SL-C (4-Channel Active LOW) | $8.50 | 5V Coil, Optocoupler isolated, 10A/250VAC |
| Power Supply | Official Raspberry Pi 27W USB-C PD PSU | $12.00 | 5V/5A PD, required for Pi 5 peripheral headroom |
| Wiring | 24 AWG Stranded Silicone (4-core) | $6.00 | Pre-tinned, high-flex |
The Pi 5 uses the standard 40-pin header layout, but remember that physical pin numbers do not match the Broadcom (BCM) GPIO numbers used in Python. Always wire by physical pin location, then code by BCM number.
| Function | Pi 5 Physical Pin | BCM GPIO Number | Wire Color (Standard) |
|---|---|---|---|
| 3.3V Power (Sensor) | Pin 1 | N/A | Red |
| I2C1 SDA | Pin 3 | GPIO 2 | Blue |
| I2C1 SCL | Pin 5 | GPIO 3 | Yellow |
| Ground (Sensor & Relay) | Pin 6 | N/A | Black |
| Relay 1 (Fan) Control | Pin 11 | GPIO 17 | Green |
| Relay 2 (Heat) Control | Pin 13 | GPIO 27 | Orange |
| 5V Power (Relay VCC) | Pin 2 | N/A | Red (Heavy Gauge) |
Wiring Steps & I2C Bus Configuration
The Raspberry Pi 5 requires the official 27W power supply to reliably source 5V to the relay coil via Pin 2. If you use a standard 15W phone charger, the Pi 5 firmware will throttle the 5V rail, causing the relays to chatter or fail to latch.
- De-energize the board. Unplug the USB-C power supply before touching the GPIO header.
- Wire the BME280 Sensor. Connect Pin 1 (3.3V) to the sensor's VIN. Connect Pin 3 to SDA, Pin 5 to SCL, and Pin 6 to GND. Note: The Adafruit breakout has 4.7kΩ pull-up resistors on the SDA/SCL lines. The Pi 5 RP1 chip has internal pull-ups, but external ones on the breakout ensure clean signal edges at 400kHz I2C speeds.
- Wire the Relay Module. Connect Pin 2 (5V) to the relay module's VCC. Connect Pin 6 (GND) to the relay GND. Connect Pin 11 to IN1 and Pin 13 to IN2.
- Enable I2C in Bookworm. Boot the Pi and open a terminal. Run
sudo raspi-config, navigate to Interface Options > I2C, and enable it. Alternatively, edit/boot/firmware/config.txtand ensuredtparam=i2c_arm=onis present and uncommented. Reboot the Pi. - Verify the Bus. After reboot, run
sudo i2cdetect -y 1. You should see77in the grid. If you see76, the sensor's address jumper is bridged; update the Python code accordingly.
Python Control Script with Error Handling
This script uses the gpiozero library for relay control and the Adafruit Blinka ecosystem for the BME280. Install the dependencies first:
sudo apt update
sudo apt install python3-pip python3-venv
python3 -m venv ~/env-controller
source ~/env-controller/bin/activate
pip3 install adafruit-circuitpython-bme280 gpiozero
Save the following code as env_controller.py. It includes explicit pin definitions, a safe cleanup routine, and threshold-based relay toggling.
import board
import busio
import adafruit_bme280
from gpiozero import OutputDevice
from time import sleep
import sys
import signal
# --- Hardware Pin Definitions (BCM Numbering) ---
RELAY_FAN_PIN = 17 # Physical Pin 11
RELAY_HEAT_PIN = 27 # Physical Pin 13
# --- Environmental Thresholds ---
TEMP_HIGH_C = 26.5 # Trigger fan above this temp
HUMID_HIGH = 65.0 # Trigger fan above this humidity
TEMP_LOW_C = 18.0 # Trigger heat below this temp
def graceful_exit(signum, frame):
print('\n[INFO] Received exit signal. Safely powering down relays...')
fan_relay.off()
heat_relay.off()
sys.exit(0)
# Register signal handlers for clean CTRL+C or systemd stops
signal.signal(signal.SIGINT, graceful_exit)
signal.signal(signal.SIGTERM, graceful_exit)
# Initialize GPIO Relays (Active LOW logic: .on() pulls pin LOW)
fan_relay = OutputDevice(RELAY_FAN_PIN, active_high=False, initial_value=False)
heat_relay = OutputDevice(RELAY_HEAT_PIN, active_high=False, initial_value=False)
# Initialize I2C Bus and Sensor
try:
i2c = busio.I2C(board.SCL, board.SDA)
bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x77)
bme280.sea_level_pressure = 1013.25
except ValueError as e:
print(f'[FATAL] I2C Sensor not found. Check wiring and address. Error: {e}')
sys.exit(1)
print('[INFO] Environmental Controller Running. Press CTRL+C to stop.')
try:
while True:
temp_c = bme280.temperature
humidity = bme280.relative_humidity
print(f'Temp: {temp_c:.1f}C | Humidity: {humidity:.1f}%')
# Control Logic with Hysteresis (prevents rapid relay clicking)
if temp_c > TEMP_HIGH_C or humidity > HUMID_HIGH:
heat_relay.off()
fan_relay.on()
print(' -> FAN ON')
elif temp_c < TEMP_LOW_C:
fan_relay.off()
heat_relay.on()
print(' -> HEAT ON')
else:
# Deadband: maintain current state or turn both off if nominal
if temp_c > (TEMP_LOW_C + 2.0) and temp_c < (TEMP_HIGH_C - 2.0):
fan_relay.off()
heat_relay.off()
print(' -> IDLE')
sleep(10) # Poll every 10 seconds
except Exception as e:
print(f'[ERROR] Unexpected runtime failure: {e}')
finally:
# Failsafe: ensure relays are de-energized on any crash
fan_relay.off()
heat_relay.off()
print('[INFO] Relays deactivated. Exiting.')
Debugging: 'OSError: [Errno 121] Remote I/O error'
When working with I2C on Linux, the most notorious failure mode is the OSError: [Errno 121] Remote I/O error. This is the kernel's way of telling you that the I2C controller sent a byte, but the slave device failed to acknowledge (NACK) it. On the Pi 5, this can also stem from the RP1 chip's strict timing requirements.
- Run
i2cdetect -y 1: If the grid shows--instead of77, the Pi cannot see the sensor at all. If it showsUU, another driver has already claimed the device. - Verify SDA/SCL Crossover: It is incredibly common to swap Pin 3 (SDA) and Pin 5 (SCL). I2C will silently fail or throw Errno 121 if the clock and data lines are reversed.
- Check 3.3V Continuity: Use a multimeter to verify exactly 3.3V (±0.1V) between the sensor's VCC and GND pins. A loose Dupont wire on the ground pin will cause the sensor's internal logic to float, resulting in a NACK.
Ranked Causes for Errno 121 on Raspberry Pi 5
| Rank | Probable Cause | Technical Fix |
|---|---|---|
| 1 | Incorrect I2C Address in Code | Change address=0x77 to address=0x76 in the Python script if the breakout board has the address jumper soldered. |
| 2 | Missing Pull-up Resistors | If using a raw BME280 chip or cheap clone breakout without pull-ups, add 4.7kΩ resistors between SDA/SCL and 3.3V. |
| 3 | RP1 I2C Clock Stretching Timeout | The Pi 5 RP1 chip is less forgiving of slow I2C slaves. Lower the bus speed by adding dtparam=i2c_arm_baudrate=10000 to /boot/firmware/config.txt. |
| 4 | 5V Logic Injected into 3.3V Bus | If you accidentally powered the sensor with 5V but connected it to the Pi's 3.3V I2C pins, the sensor's high-state output may exceed 3.3V, triggering the Pi's GPIO protection diodes and corrupting the bus. Replace sensor and check wiring. |
Extending and Simplifying the Build
Once the baseline controller is running on your bench, you will likely want to adapt it for a permanent installation. Here is how to scale the project in either direction.
How to Extend the Build
- Add MQTT Telemetry: Install
paho-mqttand publish thetemp_candhumidityvariables to a local Mosquitto broker. This allows Home Assistant to ingest the data without polling the Pi directly. - Downgrade to Pi Zero 2 W: For a deployed node, the Pi 5 is overkill and draws ~5W at idle. The code and wiring map 1:1 to the Raspberry Pi Zero 2 W. Swap the board, use a 5V/2.5A PSU, and the Python environment will run identically, dropping your power bill and heat output.
- Implement Solid State Relays (SSRs): If you are switching high-cycle loads like a grow tent exhaust fan that turns on/off every 2 minutes, mechanical Songle relays will pit and fail within months. Swap to a 40A SSR (like the Crydom D2440) driven by an optocoupler for silent, infinite-lifecycle switching.
How to Simplify the Build
- Use a Relay HAT: Eliminate the mess of Dupont wires on the 5V and GND pins by using a dedicated Relay HAT (like the Pimoroni Automation HAT or a generic 2-channel I2C relay HAT). This moves the relay control to the I2C bus, freeing up GPIO pins and removing the 5V current draw from the Pi's header.
- Switch to an All-in-One Env HAT: If you want to skip the BME280 breakout wiring entirely, the Enviro+ pack by Pimoroni plugs directly into the header and includes a BME280, PMS5003 particulate sensor, and an LCD, all accessible via pre-written Python libraries.
Building with Raspberry Pi hardware demands respect for the physical layer. A flawless Python script will still crash if your I2C pull-ups are missing or your 5V rail sags under relay load. Verify your voltages with a multimeter, test your bus with i2cdetect, and your environmental controller will run for years without intervention.






