Difficulty Rating: Intermediate (Requires basic Linux CLI and I2C wiring knowledge)
Target Board: Raspberry Pi 4 Model B (4GB) or Raspberry Pi 5 running Raspberry Pi OS (Bookworm or later)

If your Python script runs perfectly in the terminal but silently fails when scheduled, you have encountered the most common trap in embedded Linux: the cron environment. The crontab daemon executes jobs with a stripped-down PATH, no working directory context, and no access to your user's virtual environments. This guide cuts through the guesswork, providing a complete hardware build, a bulletproof Python script, and the exact debugging steps to fix cron failures on the Raspberry Pi.

The Verdict: Raspberry Pi Crontab vs. Systemd Services

Before wiring a single sensor, you must decide how your script will run. Do not default to cron just because it is familiar. Use this decision matrix to pick the right tool for your embedded project.

Criteria Raspberry Pi Crontab Systemd Service
Execution Pattern Periodic polling (e.g., every 5 minutes, daily at midnight) Continuous daemon, event listener, or hardware interrupt watcher
State Retention Stateless; script starts and stops on each run Stateful; keeps variables in memory across loops
Boot Dependency Runs strictly on time; ignores network/hardware readiness Can wait for network-online.target or specific hardware
Environment Context Minimal PATH; requires absolute paths for everything Configurable environment; can load specific .env files
The Decision Path: If your script reads a sensor, logs data, and exits in under 5 seconds, pick Crontab. If your script needs to maintain an MQTT connection, listen for GPIO button presses, or stream continuous data, pick Systemd.

Hardware Spec Sheet & Pin Mapping

We are building a scheduled climate logger that reads temperature and humidity via I2C, logs it to a CSV, and triggers a 5V exhaust fan via a relay if the temperature exceeds 28°C (82.4°F).

Parts List

  • Compute: Raspberry Pi 4 Model B (4GB) or Pi 5
  • Sensor: BME280 I2C Temperature/Humidity/Pressure breakout (Adafruit 2652 or generic equivalent)
  • Actuator: 5V 1-Channel Optocoupler Relay Module (active LOW)
  • Wiring: Female-to-female jumper wires, 22 AWG solid core for relay screw terminals
  • Power: Official 27W USB-C PD Power Supply (crucial for Pi 5 to prevent brownouts when the relay coil engages)

Pin Mapping Table

Component Pi Physical Pin GPIO / Function Wire Color (Standard)
BME280 VCCPin 13.3V PowerRed
BME280 GNDPin 6GroundBlack
BME280 SCLPin 5GPIO 3 (I2C Clock)Yellow
BME280 SDAPin 3GPIO 2 (I2C Data)Blue
Relay VCCPin 25V PowerRed
Relay GNDPin 9GroundBlack
Relay INPin 11GPIO 17 (Control)Green

The Bulletproof Python Script

This script targets the gpiozero library (standard in modern Pi OS) and smbus2 for raw I2C communication. It uses absolute paths for all file I/O, which is mandatory for cron execution.

Prerequisites: Run sudo apt update && sudo apt install python3-gpiozero python3-smbus2 i2c-tools and enable I2C via sudo raspi-config.

#!/usr/bin/env python3
"""
climate_logger.py - Scheduled BME280 Logger with Relay Fallback
Target: Raspberry Pi 4B / Pi 5 (Bookworm OS)
"""

import os
import sys
import csv
import datetime
import smbus2
import bme280
from gpiozero import OutputDevice

# --- CRITICAL: Absolute Paths for Cron Compatibility ---
BASE_DIR = '/home/pi/climate_logger'
LOG_FILE = os.path.join(BASE_DIR, 'data.csv')
ERROR_LOG = os.path.join(BASE_DIR, 'error.log')

# --- Hardware Definitions ---
I2C_BUS = 1
BME280_ADDR = 0x76  # Check with 'i2cdetect -y 1'; some boards use 0x77
RELAY_GPIO = 17
TEMP_THRESHOLD_C = 28.0

def log_error(message):
    timestamp = datetime.datetime.now().isoformat()
    with open(ERROR_LOG, 'a') as f:
        f.write(f"[{timestamp}] {message}\n")

def main():
    try:
        # Ensure base directory exists
        os.makedirs(BASE_DIR, exist_ok=True)

        # Initialize I2C and Sensor
        bus = smbus2.SMBus(I2C_BUS)
        calibration_params = bme280.load_calibration_params(bus, BME280_ADDR)
        data = bme280.sample(bus, BME280_ADDR, calibration_params)
        
        temp_c = round(data.temperature, 2)
        humidity = round(data.humidity, 2)
        
        # Initialize Relay (Active LOW for most optocoupler modules)
        # active_high=False means pin goes LOW to trigger the relay
        fan_relay = OutputDevice(RELAY_GPIO, active_high=False, initial_value=False)
        
        if temp_c >= TEMP_THRESHOLD_C:
            fan_relay.on()
            fan_state = 'ON'
        else:
            fan_relay.off()
            fan_state = 'OFF'

        # Log to CSV
        file_exists = os.path.isfile(LOG_FILE)
        with open(LOG_FILE, 'a', newline='') as f:
            writer = csv.writer(f)
            if not file_exists:
                writer.writerow(['timestamp', 'temp_c', 'humidity', 'fan_state'])
            writer.writerow([datetime.datetime.now().isoformat(), temp_c, humidity, fan_state])
            
    except FileNotFoundError as e:
        log_error(f"Path error: {e}. Did you use absolute paths?")
        sys.exit(1)
    except OSError as e:
        log_error(f"I2C/Hardware error: {e}. Check wiring and i2cdetect.")
        sys.exit(1)
    except Exception as e:
        log_error(f"Unexpected error: {e}")
        sys.exit(1)

if __name__ == '__main__':
    main()

Configuring Crontab Without the Usual Headaches

Follow these numbered steps to schedule the script safely. Never use sudo crontab -e for user-space I2C and GPIO scripts; it runs as root, which can cause permission mismatches with user-owned log files and gpiozero contexts.

  1. Make the script executable:
    chmod +x /home/pi/climate_logger/climate_logger.py
  2. Open the user crontab:
    crontab -e (Select nano if prompted).
  3. Add the scheduled job: Paste the following line at the bottom of the file. This runs the script every 5 minutes and routes both standard output and standard error to a dedicated cron log.
    */5 * * * * /usr/bin/python3 /home/pi/climate_logger/climate_logger.py >> /home/pi/climate_logger/cron.log 2>&1
  4. Save and exit: Press Ctrl+O, Enter, then Ctrl+X.
  5. Verify the daemon picked it up:
    grep CRON /var/log/syslog (or journalctl -u cron on newer systemd-heavy builds) to confirm the job is registered.
Callout Tip: Notice the 2>&1 at the end of the cron line. This redirects stderr (error messages) to stdout, appending both to cron.log. Without this, cron will attempt to email the errors to the local pi user's mailbox, which is usually unconfigured and results in silent failures.

Troubleshooting: Script Works in Terminal but Not in Cron

If your script runs flawlessly via python3 climate_logger.py but the CSV remains empty when cron triggers it, check these exact error strings in your cron.log or error.log.

The First 3 Things to Check

  1. Absolute Paths: Cron executes from the user's home directory, not the script's directory. Relative paths like open('data.csv') will fail or write to /home/pi/data.csv instead of your project folder.
  2. Stderr Routing: Ensure your crontab line includes >> /path/to/cron.log 2>&1. If you don't capture stderr, you are debugging blind.
  3. Environment Variables: Cron does not load your .bashrc. If your script relies on custom environment variables or virtual environments, you must declare them in the crontab or use absolute paths to the venv python binary.

Ranked Causes and Fixes

Exact Error String Root Cause The Fix
/bin/sh: 1: python: not found Cron's minimal PATH doesn't know where the python binary lives, and modern Pi OS aliases python to python3 only in interactive shells. Change python to /usr/bin/python3 in your crontab line.
FileNotFoundError: [Errno 2] No such file or directory: 'data.csv' The script uses a relative path. Cron's working directory is /home/pi, not the script folder. Use os.path.join(BASE_DIR, 'data.csv') with an absolute BASE_DIR variable in your Python code.
ModuleNotFoundError: No module named 'smbus2' You installed the library in a virtual environment or via pip3 install --user, which cron cannot see. Use the absolute path to the venv python (e.g., /home/pi/venv/bin/python3) or install globally via sudo apt install python3-smbus2.
PermissionError: [Errno 13] Permission denied: '/dev/i2c-1' The script is running via sudo crontab -e but the user lacks I2C group permissions, or a udev rule is blocking root access to user-space GPIO. Use the standard user crontab -e (no sudo). Ensure your user is in the i2c group: sudo usermod -aG i2c pi.

Extending and Simplifying the Build

Once your baseline cron job is stable, you can adapt the architecture to fit different project constraints.

How to Simplify

If you do not need the relay fallback and only want a pure data logger, strip out the gpiozero dependencies and the relay wiring. This reduces the script execution time to under 200 milliseconds and eliminates the risk of 5V relay coil noise causing I2C bus lockups—a common issue on long wire runs with the BME280.

How to Extend

  • Add MQTT Publishing: Instead of writing to a local CSV, use the paho-mqtt library to publish the JSON payload to a local Mosquitto broker. This allows Home Assistant to ingest the data without polling the Pi's filesystem.
  • Implement a Watchdog: If the I2C bus locks up (a known hardware quirk when the BME280 experiences voltage dips), the script will exit with an OSError. Extend the Python except block to execute a subprocess call: os.system('sudo reboot') to automatically recover the Pi from a bus fault. (Note: This requires adding a specific NOPASSWD rule in /etc/sudoers for the reboot command).
  • Switch to Systemd Timers: If you find cron's 1-minute minimum resolution too slow, or you need the script to run exactly 30 seconds after boot, migrate the crontab line to a .timer and .service file pair in /etc/systemd/system/. See the systemd.timer documentation for exact syntax.

For more on managing hardware interfaces natively, refer to the official gpiozero documentation and the crontab(5) Linux manual page for advanced scheduling syntax like running jobs only on specific weekdays.