When building reliable Raspberry Pi internet of things projects, the most common point of failure isn't the code—it's the physical layer and the communication protocol. A Raspberry Pi 5 running a Python script to read environmental data and publish it to an MQTT broker is a foundational IoT architecture. However, I2C bus capacitance, sensor burn-in times, and unhandled network drops will crash a naive script within hours. This guide details a production-grade environmental monitor using the BME680 sensor. We will cover the exact hardware variants, the I2C clock-stretching quirks specific to the Pi 5, and a complete Python implementation using the Paho MQTT v2.0 API with robust error handling.

Hardware Bill of Materials and Sensor Comparison

Before wiring anything, you need to select the right sensor. Many hobbyists default to the BME280, but for true indoor air quality (IAQ) monitoring, the BME680's volatile organic compound (VOC) gas sensor is mandatory. Below is the exact parts list for this build, followed by a data-dense comparison to justify the component selection.
Target Board Variant: This guide and code specifically target the Raspberry Pi 5 (4GB or 8GB) running Raspberry Pi OS Bookworm 64-bit. The wiring and code are backward-compatible with the Raspberry Pi 4 Model B, but the I2C bus troubleshooting section addresses Pi 5-specific silicon quirks.

Exact Parts List

  • Compute: Raspberry Pi 5 (4GB) with active cooler and 27W USB-C PD power supply (~$85 total)
  • Sensor: Pimoroni BME680 Breakout or Adafruit BME680 STEMMA QT (~$25)
  • Wiring: 4x female-to-female DuPont jumpers (24 AWG silicone, max 15cm length to minimize I2C capacitance)
  • Broker: Mosquitto MQTT running on a local NAS, Home Assistant server, or a secondary Pi Zero 2 W

Environmental Sensor Comparison Matrix

Feature Bosch BME680 Bosch BME280 Sensirion SHT40 Sensirion SCD41
Temp Accuracy ±1.0 °C ±1.0 °C ±0.2 °C ±0.4 °C
RH Accuracy ±3 % ±3 % ±1.8 % ±2 %
Gas / VOC Yes (MOX) No No No (CO2 only)
I2C Address 0x76 or 0x77 0x76 or 0x77 0x44 0x62
Avg Price (2026) $22 - $28 $10 - $14 $8 - $12 $28 - $35
Best Use Case IAQ & VOC tracking Basic weather Precision humidity CO2 ventilation

Pin Mapping and Physical Wiring

The Raspberry Pi 5's 40-pin header maintains the same I2C pinout as previous generations, but the 3.3V logic level is strictly enforced. Never connect a 5V I2C device to the Pi 5 without a bidirectional logic level converter; you will fry the BCM2712 SoC's GPIO bank.

GPIO to BME680 Pin Mapping

Pi 5 GPIO Pin (Physical) BCM Name BME680 Breakout Pin Wire Color (Standard)
Pin 1 3V3 Power VIN / 3V3 Red
Pin 6 GND GND Black
Pin 3 GPIO 2 (SDA1) SDA Blue
Pin 5 GPIO 3 (SCL1) SCL Yellow

Wiring Steps

  1. De-energize: Unplug the Pi 5's USB-C power supply before touching the GPIO header.
  2. Connect Power: Route 3.3V from Physical Pin 1 to the sensor's VIN. The BME680 has an onboard 3.3V LDO, so feeding it 5V is technically possible on some breakouts, but feeding it 3.3V directly bypasses the LDO, reducing self-heating errors by up to 0.5 °C.
  3. Connect Ground: Route GND from Physical Pin 6 to the sensor's GND.
  4. Connect I2C Data: Connect SDA (Pin 3) and SCL (Pin 5). Keep these wires under 15cm. I2C was designed for on-board communication, not long cable runs. If you must run wires further, use a STEMMA QT / Qwiic cable which provides better shielding, or drop the I2C bus speed.
  5. Verify I2C Address: Power on the Pi, open a terminal, and run i2cdetect -y 1. You should see 77 (or 76 depending on the breakout's jumper pad). If the grid is empty, check your physical connections.

Python MQTT Implementation

This script uses the Adafruit CircuitPython BME680 library (via Blinka) for sensor reads and the Eclipse Paho MQTT v2.0 client for publishing. It includes a mandatory burn-in bypass for the gas sensor and explicit exception handling for I2C and network timeouts.

Prerequisites: Run sudo apt install python3-pip python3-venv, create a virtual environment, and install dependencies via pip install adafruit-circuitpython-bme680 paho-mqtt.

import time
import json
import board
import busio
import adafruit_bme680
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion

# --- PIN & CONFIGURATION DEFINITIONS ---
# Using default I2C1 pins on Raspberry Pi 5
I2C_SDA = board.SDA  # Physical Pin 3 (GPIO 2)
I2C_SCL = board.SCL  # Physical Pin 5 (GPIO 3)

MQTT_BROKER_IP = "192.168.1.50"
MQTT_PORT = 1883
MQTT_TOPIC = "home/lab/environment/bme680"
MQTT_QOS = 1  # QoS 1 ensures at-least-once delivery for sensor data

POLL_INTERVAL_SEC = 60
GAS_BURN_IN_HOURS = 4  # MOX gas sensor requires a burn-in period for baseline

def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code == 0:
        print(f"Connected to MQTT broker at {MQTT_BROKER_IP}")
    else:
        print(f"MQTT Connection failed with reason code: {reason_code}")

# Initialize I2C bus with a lower clock speed to prevent Pi 5 clock-stretching bugs
# Standard is 100kHz, dropping to 50kHz improves reliability on long wires
i2c = busio.I2C(I2C_SCL, I2C_SDA, frequency=50000)

try:
    sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77)
    sensor.sea_level_pressure = 1013.25
    print("BME680 initialized successfully.")
except ValueError as e:
    print(f"FATAL: Could not find BME680 at address 0x77. Check wiring. Error: {e}")
    exit(1)

# Initialize MQTT Client (Paho v2.0 API)
mqtt_client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="pi5_env_monitor")
mqtt_client.on_connect = on_connect

try:
    mqtt_client.connect(MQTT_BROKER_IP, MQTT_PORT, keepalive=120)
    mqtt_client.loop_start()  # Non-blocking network loop
except ConnectionRefusedError:
    print(f"FATAL: MQTT Broker at {MQTT_BROKER_IP} refused connection. Is Mosquitto running?")
    exit(1)

burn_in_start = time.time()

try:
    while True:
        try:
            temp_c = sensor.temperature
            humidity = sensor.relative_humidity
            pressure_hpa = sensor.pressure
            gas_ohms = sensor.gas
            
            # The MOX gas sensor reads low resistance when VOCs are high.
            # Ignore gas readings during the initial burn-in period.
            uptime_hours = (time.time() - burn_in_start) / 3600
            gas_valid = uptime_hours >= GAS_BURN_IN_HOURS
            
            payload = {
                "temperature_c": round(temp_c, 2),
                "humidity_pct": round(humidity, 2),
                "pressure_hpa": round(pressure_hpa, 2),
                "gas_ohms": round(gas_ohms, 0) if gas_valid else "burning_in",
                "timestamp": int(time.time())
            }
            
            # Publish with QoS 1
            result = mqtt_client.publish(MQTT_TOPIC, json.dumps(payload), qos=MQTT_QOS)
            result.wait_for_publish()
            print(f"Published: {payload}")
            
        except OSError as e:
            print(f"I2C Read Error: {e}. Retrying in 10s...")
            time.sleep(10)
            continue
            
        except RuntimeError as e:
            print(f"Sensor calculation error: {e}")
            time.sleep(5)
            continue

        time.sleep(POLL_INTERVAL_SEC)

except KeyboardInterrupt:
    print("\nShutting down gracefully...")
    mqtt_client.loop_stop()
    mqtt_client.disconnect()
    print("MQTT disconnected. Exiting.")

Debugging: First Three Checks and Exact Error Strings

When your Raspberry Pi internet of things projects fail in production, it is almost always an I2C bus collision or a dropped network socket. If the script crashes or hangs, do not rewrite the code immediately. Perform these first three diagnostic checks.

The First Three Things to Check

  1. Check I2C Bus Visibility: Run i2cdetect -y 1. If the output shows all dashes (--), your Pi cannot see the sensor. If it shows UU, another process (like a rogue systemd service or Home Assistant container) has already claimed the I2C device handle.
  2. Check Broker Status: SSH into your MQTT server and run systemctl status mosquitto. Verify it is listening on port 1883 and hasn't crashed due to a full disk log.
  3. Check for Power Brownouts: The Pi 5 is highly sensitive to voltage drops. If the USB-C PD supply sags below 4.8V under load, the SoC will throttle and drop I2C transactions. Run dmesg | grep -i voltage. If you see "Under-voltage detected", replace your power supply or cable.

Exact Error Strings and Ranked Causes

Error String: OSError: [Errno 121] Remote I/O error
Context: Thrown during sensor.temperature read.
  • Cause 1 (Most Likely): I2C Clock Stretching Bug. The BCM2712 chip in the Pi 5 has a known silicon errata regarding I2C clock stretching. If the BME680 holds the SCL line low to process data, the Pi's I2C controller times out prematurely. Fix: Lower the I2C bus frequency to 50kHz or 25kHz in the busio.I2C() initialization, as done in the code above.
  • Cause 2: Missing Pull-up Resistors. The Pi's internal pull-ups are ~50kΩ, which is too weak for the BME680's capacitance. Fix: Use a breakout board (like Pimoroni or Adafruit) that includes onboard 4.7kΩ pull-up resistors to 3.3V.
  • Cause 3: Wire Length/Capacitance. Wires longer than 20cm act as capacitors, rounding off the square I2C clock waves. Fix: Shorten wires or add an I2C bus extender (like the PCA9615).
Error String: paho.mqtt.client.MQTTException: Invalid protocol version or ConnectionRefusedError: [Errno 111] Connection refused
Context: Thrown during mqtt_client.connect().
  • Cause 1: Paho v2.0 API Changes. If you are copying code from a 2023 tutorial, it likely uses Paho v1.6 syntax. Paho v2.0 requires the CallbackAPIVersion.VERSION2 enum in the client constructor. Fix: Update the constructor as shown in the provided script.
  • Cause 2: Mosquitto ACL / Listener Config. Modern Mosquitto (v2.0+) defaults to localhost-only and requires explicit listener definitions. Fix: Add listener 1883 0.0.0.0 and allow_anonymous true (or configure passwords) in your mosquitto.conf.

Scaling Your Raspberry Pi Internet of Things Projects

Once your BME680 monitor is stable, you will inevitably want to scale the architecture. Here is how to extend the build for a full smart-home deployment, or simplify it if you realize a full Linux OS is overkill.

How to Extend the Build

  • Home Assistant MQTT Discovery: Instead of manually configuring entities in Home Assistant, modify the Python script to publish a configuration payload to the homeassistant/sensor/bme680/config topic. This allows Home Assistant to auto-discover the temperature, humidity, and VOC entities on boot.
  • Add an e-Ink Dashboard: Wire a 2.13" Waveshare e-Paper HAT via SPI. Because the BME680 uses I2C, there is no bus conflict. You can render local graphs using the Pillow library and update the display every 5 minutes without causing screen burn-in.
  • Prometheus Exporter: If you are monitoring a server rack or greenhouse, replace the MQTT publish block with a lightweight HTTP server using Flask or FastAPI to expose a /metrics endpoint for Prometheus scraping.

How to Simplify the Build

If your goal is simply to get environmental data into Home Assistant and you do not need local data logging, camera integration, or heavy edge computing, drop the Raspberry Pi entirely. Switch to an ESP32-C3 SuperMini ($4) running ESPHome. The ESP32 handles WiFi reconnections, deep sleep, and MQTT publishing natively via YAML configuration, eliminating the need to manage Linux updates, Python virtual environments, and I2C clock-stretching bugs. Reserve the Raspberry Pi 5 for projects that require a camera, local LLM inference, or complex USB peripheral management.

For more details on configuring the Pi 5's hardware interfaces, refer to the official Raspberry Pi I2C documentation. For advanced MQTT network topology, consult the Eclipse Paho Python client docs.