10 Software — Irrigation Script

10 Software — Irrigation Script

📸 No photos here — this is a code page. The script below is the source of truth; the explainer breaks it down.

The Pi is set up; here’s what runs on it. irrigate.py reads each zone’s moisture sensor, opens that zone’s valve and runs the pump if the soil is dry, soaks, re-reads, and stops when the sensor reaches the wet threshold (or max_cycles is hit). The try/finally: all_off() block at the end is the fail-safe — if anything throws, the script closes every valve and stops the pump. The full script is below; the section-by-section walkthrough follows.

The full script

#!/usr/bin/env python3
"""
3-Zone Automated Drip Irrigation Controller
Seattle Balcony Garden

Hardware:
  - 1× submersible pump (relay CH1, GPIO 17)
  - 3× DIGITEN 12V solenoid valves (relay CH2-4, GPIO 27/22/23)
  - 3× capacitive soil moisture sensors via ADS1115 ADC (I2C 0x48)
  - 1× float switch (hardware cutoff on 12V line)

Zones:
  Zone 1 (lettuce):    3 pots,  3 emitters, sensor on ADS1115 CH0
  Zone 2 (tomatoes):  10 pots, 20 emitters, sensor on ADS1115 CH1
  Zone 3 (raised bed): 1 bed,   5 emitters, sensor on ADS1115 CH2
"""

import time
import datetime
import json
import os
import sys
import RPi.GPIO as GPIO
import board
import busio
import adafruit_ads1x15.ads1115 as ADS
from adafruit_ads1x15.analog_in import AnalogIn

# ────────────────────────────────────────────────────────────────
# CONFIGURATION — edit these values after calibration
# ────────────────────────────────────────────────────────────────

PUMP_PIN = 17

ZONES = {
    "lettuce": {
        "valve_pin": 27,
        "sensor_channel": 0,
        "description": "Lettuce & salad mix (3 pots, 3 emitters)",
        "dry_threshold": 18000,   # Lettuce likes it moist
        "wet_threshold": 12000,   # Stop watering at this level
        "pump_seconds": 12,       # Short zone, few emitters
        "max_cycles": 4,
    },
    "tomatoes": {
        "valve_pin": 22,
        "sensor_channel": 1,
        "description": "Tomatoes (10 pots, 20 emitters)",
        "dry_threshold": 21000,   # Tomatoes tolerate slightly drier
        "wet_threshold": 13000,
        "pump_seconds": 35,       # Longest zone, most emitters
        "max_cycles": 4,
    },
    "raised_bed": {
        "valve_pin": 23,
        "sensor_channel": 2,
        "description": "Raised bed — peas, flowers, radishes (5 emitters)",
        "dry_threshold": 20000,
        "wet_threshold": 12500,
        "pump_seconds": 20,
        "max_cycles": 4,
    },
}

SOAK_WAIT_SECONDS = 60    # Wait between watering cycles for soak-in
SENSOR_SAMPLES = 10       # Number of ADC readings to average
SENSOR_SAMPLE_DELAY = 0.1 # Seconds between ADC samples
RELAY_ACTIVE_LOW = True   # Most opto-isolated relay modules trigger LOW
VALVE_SETTLE_TIME = 0.5   # Seconds to let valve open/close before pump

LOG_DIR = os.path.expanduser("~/irrigation/logs")
LOG_FILE = os.path.join(LOG_DIR, "irrigation.json")
TEXT_LOG = os.path.join(LOG_DIR, "irrigation.log")

# ────────────────────────────────────────────────────────────────
# SETUP
# ────────────────────────────────────────────────────────────────

os.makedirs(LOG_DIR, exist_ok=True)

GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)

ALL_PINS = [PUMP_PIN] + [z["valve_pin"] for z in ZONES.values()]
for pin in ALL_PINS:
    GPIO.setup(pin, GPIO.OUT)
    GPIO.output(pin, GPIO.HIGH if RELAY_ACTIVE_LOW else GPIO.LOW)

i2c = busio.I2C(board.SCL, board.SDA)
ads = ADS.ADS1115(i2c)
ads.gain = 1  # ±4.096V range

# ────────────────────────────────────────────────────────────────
# FUNCTIONS
# ────────────────────────────────────────────────────────────────

def log_text(msg):
    """Append a timestamped message to the text log and print it."""
    ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line)
    with open(TEXT_LOG, "a") as f:
        f.write(line + "\n")


def log_json(data):
    """Append a JSON event to the structured log."""
    data["timestamp"] = datetime.datetime.now().isoformat()
    try:
        with open(LOG_FILE, "r") as f:
            log = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        log = []
    log.append(data)
    # Keep last 2000 entries (~6 months of 2x daily runs)
    log = log[-2000:]
    with open(LOG_FILE, "w") as f:
        json.dump(log, f, indent=2)


def relay_on(pin):
    GPIO.output(pin, GPIO.LOW if RELAY_ACTIVE_LOW else GPIO.HIGH)

def relay_off(pin):
    GPIO.output(pin, GPIO.HIGH if RELAY_ACTIVE_LOW else GPIO.LOW)

def all_off():
    """Emergency stop — kill pump and all valves."""
    for pin in ALL_PINS:
        relay_off(pin)


def read_moisture(channel):
    """Read the moisture sensor on the given ADC channel.
    Returns the average of SENSOR_SAMPLES readings.
    Higher value = drier soil. Lower value = wetter soil.
    """
    readings = []
    for _ in range(SENSOR_SAMPLES):
        try:
            chan = AnalogIn(ads, channel)
            readings.append(chan.value)
        except Exception as e:
            log_text(f"  ADC read error on channel {channel}: {e}")
        time.sleep(SENSOR_SAMPLE_DELAY)

    if not readings:
        log_text(f"  WARNING: No valid readings from channel {channel}")
        return -1  # Signal error

    avg = int(sum(readings) / len(readings))
    return avg


def water_zone(zone_name, cfg):
    """Check moisture and water a single zone if dry."""
    valve_pin = cfg["valve_pin"]
    channel = cfg["sensor_channel"]
    dry_thresh = cfg["dry_threshold"]
    wet_thresh = cfg["wet_threshold"]
    pump_secs = cfg["pump_seconds"]
    max_cyc = cfg["max_cycles"]
    desc = cfg["description"]

    log_text(f"\n--- {desc} ---")

    # Read moisture
    moisture = read_moisture(channel)
    if moisture == -1:
        log_text(f"  Sensor error. Skipping zone for safety.")
        log_json({"zone": zone_name, "action": "error", "reason": "sensor_read_failed"})
        return

    log_text(f"  Moisture reading: {moisture} (dry threshold: {dry_thresh})")

    # Check if watering is needed
    if moisture <= dry_thresh:
        log_text(f"  Soil is moist enough. Skipping.")
        log_json({"zone": zone_name, "action": "skip", "moisture": moisture})
        return

    # Soil is dry — begin watering cycles
    log_text(f"  Soil is DRY. Beginning watering cycles...")
    cycles = 0

    while moisture > wet_thresh and cycles < max_cyc:
        cycles += 1
        log_text(f"  Cycle {cycles}/{max_cyc}: valve open + pump for {pump_secs}s")

        # Open valve first, then start pump
        relay_on(valve_pin)
        time.sleep(VALVE_SETTLE_TIME)
        relay_on(PUMP_PIN)

        # Run for specified duration
        time.sleep(pump_secs)

        # Stop pump first, then close valve (avoids pressure spike)
        relay_off(PUMP_PIN)
        time.sleep(VALVE_SETTLE_TIME)
        relay_off(valve_pin)

        # Wait for water to soak into soil before re-reading
        log_text(f"  Pump off. Waiting {SOAK_WAIT_SECONDS}s for soak-in...")
        time.sleep(SOAK_WAIT_SECONDS)

        # Re-read moisture
        moisture = read_moisture(channel)
        if moisture == -1:
            log_text(f"  Sensor error during re-check. Stopping zone.")
            all_off()
            break
        log_text(f"  Re-check moisture: {moisture}")

    log_text(f"  Zone complete. {cycles} cycle(s), final moisture: {moisture}")
    log_json({
        "zone": zone_name,
        "action": "watered",
        "cycles": cycles,
        "final_moisture": moisture,
    })


# ────────────────────────────────────────────────────────────────
# MAIN
# ────────────────────────────────────────────────────────────────

def main():
    log_text(f"\n{'='*60}")
    log_text(f"IRRIGATION RUN STARTING")
    log_text(f"{'='*60}")

    for zone_name, cfg in ZONES.items():
        try:
            water_zone(zone_name, cfg)
        except Exception as e:
            log_text(f"  EXCEPTION in {zone_name}: {e}")
            log_json({"zone": zone_name, "action": "exception", "error": str(e)})
            all_off()
            # Continue to next zone rather than aborting entire run
            continue

    log_text(f"\nAll zones checked. Run complete.")
    log_text(f"{'='*60}\n")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        log_text("\nInterrupted by user (Ctrl+C).")
    except Exception as e:
        log_text(f"\nFATAL ERROR: {e}")
        log_json({"action": "fatal", "error": str(e)})
    finally:
        all_off()
        GPIO.cleanup()
        log_text("All relays OFF. GPIO cleaned up. Exiting.")

If you ran setup-pi.sh from page 09, this script is already at ~/irrigation/irrigate.py. The breakdown below explains what each section does so you know what to tune (and what not to touch).

The CONFIGURATION block, line-by-line

The CONFIGURATION block at lines 33-72 is where every per-build value lives. Read it once top to bottom, change only what’s flagged below, and leave everything else at the defaults.

PUMP_PIN = 17

The GPIO line driving the pump’s relay channel. Matches page 08’s GPIO-to-relay table (GPIO 17 = relay IN1 = pump). Change only if your wiring deviates from that map.

ZONES = { ... }

A dictionary, one entry per zone. Each zone has 7 fields:

If you have more or fewer than 3 zones, see “Adapting for more zones” below.

Other constants

I shipped my first build with pump_seconds = 30 for the lettuce zone (the default I’d written before counting emitters) and the lettuce was wading in mud by Tuesday. The current 12 is what you actually want for 3 emitters on a small zone. The point: these defaults are starting values. Watch the soil for the first week and tune.

The watering loop — water_zone()

water_zone() lives at lines 155-221. It’s the heart of the script: read the sensor, decide whether to water, then water in cycles with a soak wait between each cycle until the sensor says “wet enough” or you’ve hit max_cycles.

The cycle, step by step:

  1. Read the sensor. Average SENSOR_SAMPLES (10) ADC reads. If above dry_threshold, the zone needs water; if below, log “soil is moist enough” and skip the zone.
  2. Open the valve. Set the zone’s valve_pin to LOW (active-low). Wait VALVE_SETTLE_TIME (0.5s) so the valve is fully open before pressure hits it.
  3. Run the pump. Set PUMP_PIN to LOW for pump_seconds (12 / 35 / 20 depending on zone).
  4. Stop the pump first. Set PUMP_PIN back to HIGH. Wait VALVE_SETTLE_TIME. Pump-off-before-valve-close avoids the small pressure spike you’d get from closing a valve against a running pump.
  5. Close the valve. Restore the zone’s valve_pin to HIGH.
  6. Soak. Wait SOAK_WAIT_SECONDS (60s) for water to distribute through the soil.
  7. Re-read the sensor. If now below wet_threshold, the cycle is done — log final moisture and return. If still above, increment cycle count and go back to step 2.
  8. Bail at max_cycles. If we’ve hit the cycle limit, log the dryness and stop. Probably a stuck sensor or a clogged emitter; do not keep pumping forever.

Each zone runs this loop sequentially — no parallel watering. With 3 zones at 3-5 minutes each (including soak waits), a complete watering pass takes 10-15 minutes. Cron’s twice-daily 7am + 6pm schedule (page 12) reaches every zone twice a day with margin.

The fail-safe block

The try/finally: all_off() block at lines 247-258 is the universal safety net — re-read it on every Pi-driven irrigation project you ever build. Even the advanced-mode page shows it because it matters that much.

The entire main() body runs inside a try/finally: block. If the script throws ANY exception — a sensor I/O error, a GPIO permission failure, a KeyboardInterrupt from someone hitting Ctrl+C, even an uncaught Python error in code you wrote — the finally: clause runs all_off(). all_off() sets every relay to HIGH (open), which means pump off and all valves closed. The script then calls GPIO.cleanup() so the pins are released cleanly for the next run.

A runaway zone — say, a sensor stuck reading “dry” — gets bounded twice: once by max_cycles (the per-zone software limit), and once by the float switch on the 12V line (the hardware safety net from page 08). If both fail simultaneously, the script’s exception handler still hits all_off() on the next abnormal shutdown. Three layers of protection — software cycle limit, hardware float switch, and exception-driven all-off — is the floor for anything that pumps water near electronics.

Adapting for more zones

The v1 script is hardcoded 3-zone, but the planner and the hardware scale to 12. To run more or fewer zones, edit the ZONES dict directly. Add or remove entries; each entry takes the 7 fields listed above. The script iterates ZONES.items() so any number of entries works — no other code changes needed. Keep the keys ("lettuce", "tomatoes", "raised_bed") matching your planner’s zone labels so the log output stays readable.

Each new zone needs a free GPIO line for its valve (the relay module on page 08 supports 4 channels by default; for more than 4 zones, add a second 8-channel relay module — page 08’s wiring scales). It also needs a free ADS1115 channel for its sensor (the ADS1115 has 4 channels A0/A1/A2/A3; for more than 4 zones, daisy-chain a second ADS1115 by tying its ADDR pin to VDD instead of GND, which puts it at I2C address 0x49).

Manual watering

For one-off zone tests during initial setup or troubleshooting, ~/irrigation/manual_water.py opens a single zone for N seconds:

cd ~/irrigation
source venv/bin/activate
python3 manual_water.py tomatoes 30   # opens the tomato valve, runs the pump for 30s

Page 12 covers how to disable cron temporarily during diagnostic sessions so the automated run does not collide with your manual one.

Run it once before automating

Before letting cron take over, run the script manually so you can watch the logs:

cd ~/irrigation
source venv/bin/activate
python3 irrigate.py

Confirm each zone fires (the relays click, the pump runs, water reaches each zone) and the JSON log file at ~/irrigation/logs/irrigation.json accumulates one entry per zone. If anything goes wrong, the finally: all_off() block keeps you safe — cancel with Ctrl+C and re-check the wiring. Once the manual run succeeds end-to-end, cron’s twice-daily schedule (page 12) takes over.

For the actual dry_threshold and wet_threshold values your soil and sensors need, see page 11’s calibration calculator — you take two readings (dry, then wet), the calculator outputs the threshold, and you paste it into the ZONES dict above. Page 11 covers wet_threshold too, so the per-zone ADC numbers in this script have a single source of truth.

Once the manual run looks clean, page 11 hands you the calibration calculator that turns your two multimeter readings into the dry_threshold and wet_threshold values you paste back into this script.