Project Overview: I2C Climate & MQTT Node

If you are staring at a bare board wondering what to do with Raspberry Pi hardware, building a headless environmental telemetry node is the perfect intersection of hardware wiring, Linux system administration, and Python scripting. This project reads temperature, humidity, and barometric pressure via I2C and publishes the payload to an MQTT broker for integration with platforms like Home Assistant or Node-RED.

Build Specifications

  • Target Board Variant: Raspberry Pi 4 Model B (4GB RAM, Rev 1.4) or Raspberry Pi 5 (4GB). The code and pinouts are identical for both.
  • Operating System: Raspberry Pi OS (64-bit, Bookworm or newer).
  • Difficulty Rating: Intermediate (requires basic Linux CLI and I2C bus knowledge).
  • Estimated Time: 45 minutes.

Exact Parts List

Component Exact Variant / Part Number Approx. Cost
Microcomputer Raspberry Pi 4 Model B (4GB) or Pi 5 $55 - $60
Sensor Adafruit BME280 I2C/SPI Breakout (Product ID: 2652) $10
Wiring 28 AWG Silicone Jumper Wires (Male-to-Female) $6
Power Supply Official Raspberry Pi 27W USB-C PD Power Supply (for Pi 5) or 15W (for Pi 4) $12

Hardware Wiring & Pin Mapping

The Raspberry Pi's 40-pin header exposes the primary I2C bus (I2C1) on physical pins 3 and 5. The BME280 breakout board operates strictly at 3.3V logic, which perfectly matches the Pi's GPIO levels. Never connect 5V logic sensors directly to the Pi's SDA/SCL lines without a bidirectional logic level converter, or you risk frying the SoC's I2C pull-up resistors.

Pi Physical Pin BCM GPIO Function BME280 Breakout Pin
1 N/A 3.3V Power VIN / VCC
6 N/A Ground GND
3 GPIO 2 I2C1 SDA SDI / SDA
5 GPIO 3 I2C1 SCL SCK / SCL
Bench Tip: The Raspberry Pi has internal 1.8kΩ pull-up resistors on the I2C lines tied to 3.3V. For runs under 30cm (12 inches) using standard 28 AWG wire, this is sufficient. If you experience intermittent drops, solder external 4.7kΩ pull-up resistors between SDA/VCC and SCL/VCC on the sensor breakout to overcome wire capacitance.

Software Setup & Compilable Python Code

Starting with Raspberry Pi OS Bookworm, Python environment management changed significantly due to PEP 668. You can no longer install packages globally via pip without breaking system dependencies. We will use a virtual environment.

1. Enable I2C and Install System Dependencies

Open your terminal and enable the I2C interface:

sudo raspi-config nonint do_i2c 0
sudo apt update
sudo apt install python3-smbus i2c-tools python3-venv -y

Verify the sensor is visible on the bus (default BME280 address is 0x77 or 0x76):

i2cdetect -y 1

2. Create Virtual Environment & Install Libraries

mkdir ~/climate_node && cd ~/climate_node
python3 -m venv venv
source venv/bin/activate
pip install smbus2 RPi.bme280 paho-mqtt

3. The Python Telemetry Script

Save the following code as mqtt_climate.py. This script includes explicit pin/bus definitions, handles I2C bus exceptions, and manages MQTT connection state.

#!/usr/bin/env python3
import time
import json
import smbus2
import bme280
import paho.mqtt.client as mqtt
import sys

# --- PIN & BUS DEFINITIONS ---
I2C_BUS_ID = 1          # Physical pins 3 (SDA) and 5 (SCL)
BME280_ADDRESS = 0x76   # Change to 0x77 if your breakout has the alternate addr
MQTT_BROKER_IP = "192.168.1.100"
MQTT_PORT = 1883
MQTT_TOPIC = "home/office/climate"
SAMPLE_INTERVAL_SEC = 30

# --- MQTT CALLBACKS ---
def on_connect(client, userdata, flags, rc, properties=None):
    if rc == 0:
        print(f"[MQTT] Connected to {MQTT_BROKER_IP}")
    else:
        print(f"[MQTT] Connection failed with code {rc}")

def on_publish(client, userdata, mid, rc, properties=None):
    pass # Suppress publish logs to keep console clean

# --- MAIN EXECUTION ---
def main():
    # Initialize I2C Bus
    try:
        bus = smbus2.SMBus(I2C_BUS_ID)
        calibration_params = bme280.load_calibration_params(bus, BME280_ADDRESS)
        print("[I2C] BME280 initialized successfully.")
    except FileNotFoundError:
        print("[FATAL] I2C bus not found. Did you enable I2C in raspi-config?")
        sys.exit(1)
    except OSError as e:
        print(f"[FATAL] I2C Hardware Error: {e}")
        sys.exit(1)

    # Initialize MQTT Client (Paho v2.0+ API)
    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    client.on_connect = on_connect
    client.on_publish = on_publish
    
    try:
        client.connect(MQTT_BROKER_IP, MQTT_PORT, 60)
        client.loop_start()
    except Exception as e:
        print(f"[MQTT] Broker unreachable: {e}. Continuing in local-only mode.")

    print(f"Starting telemetry loop (Interval: {SAMPLE_INTERVAL_SEC}s)...")
    
    try:
        while True:
            try:
                # Read Sensor Data
                data = bme280.sample(bus, BME280_ADDRESS, calibration_params)
                
                payload = {
                    "temperature_c": round(data.temperature, 2),
                    "humidity_pct": round(data.humidity, 2),
                    "pressure_hpa": round(data.pressure, 2),
                    "timestamp": time.time()
                }
                
                json_payload = json.dumps(payload)
                print(f"[DATA] {json_payload}")
                
                # Publish to MQTT
                if client.is_connected():
                    client.publish(MQTT_TOPIC, json_payload, qos=1)
                    
            except OSError as e:
                # Catching the infamous I2C dropout error
                print(f"[ERROR] I2C Read Failure: {e}. Retrying next cycle.")
                
            time.sleep(SAMPLE_INTERVAL_SEC)
            
    except KeyboardInterrupt:
        print("\n[SYSTEM] Shutting down gracefully...")
    finally:
        client.loop_stop()
        client.disconnect()
        bus.close()
        print("[SYSTEM] I2C bus and MQTT disconnected.")

if __name__ == "__main__":
    main()

Debugging: Fixing "OSError: [Errno 121] Remote I/O error"

When working with I2C on the Pi, you will inevitably encounter the following exact error string:

OSError: [Errno 121] Remote I/O error

This is a low-level kernel rejection indicating the SoC sent a clock pulse but received no ACKnowledge (ACK) bit back from the sensor. Here are the first three things to check when this occurs:

  1. Verify Kernel Module Loading: Run lsmod | grep i2c. If i2c_dev and i2c_bcm2835 are missing, the interface is disabled or the overlay failed to load in /boot/firmware/config.txt.
  2. Poll the Bus Address: Run i2cdetect -y 1. If the grid is entirely empty, you have a wiring fault. If you see UU, another process (like a system daemon) has already claimed the sensor.
  3. Check Ground Continuity: Use a multimeter in continuity mode to test between the GND pin on the sensor breakout and the metal shield of the Pi's USB port. A floating ground will cause the I2C logic levels to drift, triggering Errno 121.

Ranked Causes for Persistent I2C Errors

Rank Cause Fix
1 I2C interface disabled in OS Run sudo raspi-config -> Interface Options -> I2C -> Enable.
2 Incorrect I2C Address in Code BME280 breakouts default to 0x76 or 0x77. Check the silkscreen on your specific board and update the BME280_ADDRESS variable.
3 Parasitic Capacitance on SDA/SCL Long jumper wires act as capacitors, rounding off the square clock waves. Keep I2C runs under 30cm, or add 4.7kΩ external pull-up resistors.
4 Insufficient Power Delivery If the Pi's 3.3V rail sags under load, the sensor will brownout. Measure Pin 1 with a multimeter; it must read between 3.25V and 3.35V.

Extending and Simplifying the Build

Depending on your deployment environment, you may need to scale this project up or down.

How to Simplify (Local Logging)

If you don't have an MQTT broker running and just want to log data for a science project or greenhouse monitor, strip out the paho-mqtt dependencies. Replace the publish block with Python's built-in csv module to append rows to a local data.csv file on the Pi's microSD card. This reduces network overhead and makes the node entirely self-contained.

How to Extend (Home Assistant Integration)

To make this node natively discoverable by Home Assistant, implement MQTT Discovery. Instead of publishing just to home/office/climate, publish a JSON configuration payload to homeassistant/sensor/office_climate/config. This tells Home Assistant exactly what the sensor is, its units of measurement, and the state topic to listen to, automatically generating dashboard entities without manual YAML configuration.

Frequently Asked Questions

What to do with Raspberry Pi when you don't have a monitor?

You can run the Pi "headless" by configuring it before the first boot. Flash the OS using the official Raspberry Pi Imager, click the gear icon (or OS Customisation menu), and enable SSH, set your WiFi credentials, and define your username/password. Once powered on, locate the Pi's IP address via your router's DHCP table and connect using ssh username@raspberrypi.local. For embedded nodes like this climate logger, headless operation is the standard deployment method.

What to do with old Raspberry Pi 2 or 3 boards in 2026?

While the Pi 2 and Pi 3 struggle with modern heavy web browsing, they are still excellent for low-overhead background tasks. A Raspberry Pi 3B+ is perfectly suited to run Pi-hole (network-wide ad blocking) or act as a dedicated local Mosquitto MQTT broker for your smart home devices. The Pi 2, with its limited 1GB RAM and slower CPU, is best repurposed as a lightweight OctoPrint server for 3D printer management or a basic digital signage driver using a lightweight framebuffer image viewer.

What to do with Raspberry Pi GPIO pins safely?

The golden rule of Pi GPIO is respecting the 3.3V logic limit and current constraints. Each GPIO pin can safely source or sink a maximum of 16mA, and the total current drawn from all 3.3V pins combined must not exceed 50mA. If you need to switch a 5V relay or a 12V solenoid, never connect it directly to the GPIO. Use a logic-level N-channel MOSFET (like the IRLZ44N) or an optocoupler to isolate the Pi's sensitive SoC from inductive kickback and higher voltage rails.