Target Board and Component Selection

For a dedicated, always-on environmental monitor, the Raspberry Pi Zero 2 W is the definitive target board. It draws roughly 1.2W at idle and peaks around 2.5W under load, making it vastly superior to the Pi 4 or Pi 5 for off-grid or solar-powered weather stations where every milliamp-hour counts. This build targets the Pi Zero 2 W running Raspberry Pi OS Lite (64-bit, Bookworm).

A common mistake in DIY meteorology is relying on the cheap DHT11 or DHT22 sensors. These suffer from severe hysteresis and drift. Instead, we use the Bosch BME280 for ambient air (temperature, humidity, barometric pressure) and a waterproof DS18B20 for ground or outdoor probe temperature. We will also integrate a BH1750 digital ambient light sensor for lux tracking.

Enclosure Note: Never mount environmental sensors in a sealed plastic box; solar loading and trapped humidity will ruin your data. Use a Stevenson screen (louvered wooden or 3D-printed enclosure) to allow passive airflow while shielding sensors from direct radiation and precipitation, per WMO guidelines.

Sensor Specifications and Bus Requirements

Before wiring, you must understand the electrical characteristics of your sensors. The I2C bus is particularly unforgiving of long wire runs and missing pull-up resistors. Below is the exact specification matrix for the components used in this build.

Sensor Module Measured Variables Interface Default Hex Address Operating Range Accuracy / Resolution
BME280 (Bosch) Temp, Humidity, Pressure I2C / SPI 0x76 (SDO=GND) or 0x77 -40°C to +85°C ±1.0°C / ±3% RH / ±1 hPa
DS18B20 (Maxim) Outdoor/Ground Temp 1-Wire N/A (ROM Serial) -55°C to +125°C ±0.5°C (from -10°C to +85°C)
BH1750 (ROHM) Ambient Light (Lux) I2C 0x23 (ADDR=L) 1 to 65,535 lx ±20% (Resolution down to 1 lx)
Pi Zero 2 W Compute / Logging GPIO Header N/A 0°C to +70°C (SoC) Quad-core 1.0GHz / 512MB RAM

Wiring and Pin Mapping

Bus capacitance on the I2C lines (SDA/SCL) is strictly limited to 400pF by the NXP I2C specification. Standard 22 AWG jumper wire adds roughly 15-20pF per foot. If you route your BME280 outside the enclosure using 15 feet of cable, you will exceed the capacitance limit, resulting in corrupted data or total bus lockups. Keep I2C wire runs under 1 meter (3 feet). If you need longer runs, use an I2C bus extender IC like the P82B96.

Pi Zero 2 W Pin (BCM) Physical Pin # Target Sensor Pin Notes & Required Passives
3.3V Power 1 BME280 VCC, BH1750 VCC Do NOT use 5V; BME280 is strictly 3.3V logic.
5V Power 2 DS18B20 VDD (Red) Required for external power mode (not parasitic).
GPIO 2 (SDA1) 3 BME280 SDA, BH1750 SDA Pi has onboard 1.8k pull-ups; sufficient for <1m runs.
GPIO 3 (SCL1) 5 BME280 SCL, BH1750 SCL Shared I2C clock line.
GPIO 4 (1-Wire) 7 DS18B20 DATA (Yellow) Requires 4.7kΩ pull-up resistor to 3.3V.
Ground 6, 9, 14 All Sensor GND pins Ensure common ground reference across all modules.
1-Wire Pull-Up Warning: The Raspberry Pi's internal pull-up resistors are roughly 50kΩ. This is far too weak for the 1-Wire protocol, which requires a hard 4.7kΩ pull-up to meet the strict microsecond timing edges. Solder a physical 4.7kΩ resistor between the DS18B20 DATA line and the 3.3V rail, or the kernel will intermittently drop the sensor.

Software Setup and Python Data Logger

First, enable the I2C and 1-Wire interfaces via the Raspberry Pi configuration tool. Open your terminal and run sudo raspi-config. Navigate to Interface Options -> I2C (Enable), and Interface Options -> 1-Wire (Enable). Reboot the Pi.

Next, install the required Python libraries in a virtual environment to comply with PEP 668 (externally managed environments in Bookworm):

python3 -m venv ~/weather_env
source ~/weather_env/bin/activate
pip install adafruit-circuitpython-bme280 w1thermsensor smbus2

Below is the complete, compilable Python script. It initializes the sensors, handles bus exceptions gracefully, and logs the output to both the console and a CSV file.

import time
import csv
import os
import board
import busio
import adafruit_bme280
from w1thermsensor import W1ThermSensor, Sensor
from smbus2 import SMBus
from datetime import datetime

# --- Pin & Address Definitions ---
I2C_BUS_ID = 1
BH1750_ADDR = 0x23
BME280_ADDR = 0x76  # Assumes SDO is tied to GND
CSV_FILE = "weather_log.csv"

# --- Hardware Initialization ---
try:
    # Initialize I2C bus for BME280 (using Adafruit Blinka)
    i2c = busio.I2C(board.SCL, board.SDA)
    bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=BME280_ADDR)
    bme280.sea_level_pressure = 1013.25  # Adjust to your local altitude

    # Initialize 1-Wire for DS18B20
    ds18b20 = W1ThermSensor(Sensor.DS18B20)

    # Initialize SMBus for BH1750 (Light Sensor)
    smbus = SMBus(I2C_BUS_ID)
    
    print("[INFO] All sensors initialized successfully.")

except FileNotFoundError as e:
    print(f"[FATAL] I2C bus not found. Did you enable I2C in raspi-config? Error: {e}")
    exit(1)
except Exception as e:
    print(f"[FATAL] Hardware initialization failed: {e}")
    exit(1)

# --- CSV Header Setup ---
if not os.path.exists(CSV_FILE):
    with open(CSV_FILE, mode='w', newline='') as file:
        writer = csv.writer(file)
        writer.writerow(["Timestamp", "Temp_C", "Humidity_%", "Pressure_hPa", "Ground_Temp_C", "Lux"])

# --- Main Logging Loop ---
try:
    while True:
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        
        # Read BME280
        try:
            amb_temp = round(bme280.temperature, 2)
            humidity = round(bme280.relative_humidity, 2)
            pressure = round(bme280.pressure, 2)
        except OSError as e:
            print(f"[WARN] BME280 I2C Read Error: {e}")
            amb_temp, humidity, pressure = None, None, None

        # Read DS18B20
        try:
            ground_temp = round(ds18b20.get_temperature(), 2)
        except Exception as e:
            print(f"[WARN] DS18B20 Read Error: {e}")
            ground_temp = None

        # Read BH1750
        try:
            raw_lux = smbus.read_i2c_block_data(BH1750_ADDR, 0x10, 2)
            lux = round((raw_lux[0] << 8 | raw_lux[1]) / 1.2, 1)
        except OSError as e:
            print(f"[WARN] BH1750 I2C Read Error: {e}")
            lux = None

        # Log to Console and CSV
        print(f"{timestamp} | {amb_temp}°C | {humidity}% | {pressure}hPa | Gnd:{ground_temp}°C | {lux}lx")
        
        with open(CSV_FILE, mode='a', newline='') as file:
            writer = csv.writer(file)
            writer.writerow([timestamp, amb_temp, humidity, pressure, ground_temp, lux])

        # BME280 requires a pause between high-resolution reads to avoid self-heating
        time.sleep(60) 

except KeyboardInterrupt:
    print("\n[INFO] Logging stopped by user.")
finally:
    smbus.close()

Debugging: The First Three Things to Check

When deploying embedded hardware outdoors, things will fail. If your script crashes or returns null data, do not rewrite the code. Check the physical and kernel layers first using this ranked decision path.

1. The I2C NACK: OSError: [Errno 121] Remote I/O error

This exact string means the Pi sent an address over the SDA line, but no device acknowledged it (NACK).
Causes & Fixes:

  • Wrong Address: Many cheap BME280 breakouts have the SDO pin pulled HIGH by default, making the address 0x77 instead of 0x76. Run i2cdetect -y 1 in the terminal. If you see 77, update the BME280_ADDR variable in the code.
  • Missing Pull-ups: If your breakout board lacks onboard pull-up resistors and you are using long wires, the signal edges are too slow. Add 4.7kΩ resistors to SDA and SCL.
  • Bus Lockup: A brownout can leave the I2C bus in a locked state. Reboot the Pi or physically disconnect the VCC line to the sensors for 5 seconds to reset the bus logic.

2. The 1-Wire Ghost: w1thermsensor.errors.NoSensorFoundError

The Python library cannot find the kernel module mapping for the DS18B20.
Causes & Fixes:

  • Missing Device Tree Overlay: In newer Raspberry Pi OS versions, the 1-Wire overlay must be explicitly defined. Open /boot/firmware/config.txt and ensure the line dtoverlay=w1-gpio,gpiopin=4 is present at the bottom. Reboot.
  • Parasitic Power Failure: If you wired the DS18B20 in parasitic power mode (VDD tied to GND), the Pi's GPIO cannot source enough current for the temperature conversion phase. Wire VDD to 3.3V or 5V explicitly.

3. The Environment Block: error: externally-managed-environment

This occurs when you try to run pip install globally on Raspberry Pi OS Bookworm.
Fix: You must use a virtual environment (as shown in the setup steps) or the pipx utility. Never use the --break-system-packages flag, as it can corrupt the OS-level Python dependencies required by raspi-config.

Extending or Simplifying the Build

This architecture is modular. Depending on your deployment constraints, you can scale the hardware up or down without rewriting the core logic.

How to Simplify (Low Cost / Low Power)

If you are deploying a dozen nodes on a tight budget, drop the DS18B20 and BH1750. The BME280 alone provides highly accurate ambient temperature, humidity, and pressure. By removing the 1-Wire bus, you eliminate the need for the 4.7kΩ pull-up resistor and reduce the Pi's idle current draw by roughly 1mA. You can power this simplified node for weeks on a standard 10,000mAh LiPo battery pack.

How to Extend (Professional Telemetry)

To turn this into a professional telemetry node, add a LoRaWAN HAT (like the Dragino LoRa/GPS HAT) and an anemometer (wind speed). Wind speed sensors typically use a reed switch that closes once per revolution. You can wire this to a spare GPIO (e.g., GPIO 17) and use the Pi's hardware interrupt capabilities via the gpiozero library's Button class to count pulses without blocking the main Python loop. Push the aggregated CSV data to an InfluxDB instance using the influxdb-client Python library, and visualize it on a Grafana dashboard.