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 |
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 VCC | Pin 1 | 3.3V Power | Red |
| BME280 GND | Pin 6 | Ground | Black |
| BME280 SCL | Pin 5 | GPIO 3 (I2C Clock) | Yellow |
| BME280 SDA | Pin 3 | GPIO 2 (I2C Data) | Blue |
| Relay VCC | Pin 2 | 5V Power | Red |
| Relay GND | Pin 9 | Ground | Black |
| Relay IN | Pin 11 | GPIO 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.
- Make the script executable:
chmod +x /home/pi/climate_logger/climate_logger.py - Open the user crontab:
crontab -e(Selectnanoif prompted). - 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 - Save and exit: Press
Ctrl+O,Enter, thenCtrl+X. - Verify the daemon picked it up:
grep CRON /var/log/syslog(orjournalctl -u cronon newer systemd-heavy builds) to confirm the job is registered.
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
- 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.csvinstead of your project folder. - Stderr Routing: Ensure your crontab line includes
>> /path/to/cron.log 2>&1. If you don't capture stderr, you are debugging blind. - 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-mqttlibrary 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 Pythonexceptblock 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/sudoersfor 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
.timerand.servicefile 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.






