10 Software — Irrigation Script
10 Software — Irrigation Script
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:
valve_pin— GPIO line for that zone’s solenoid relay (lettuce: 27, tomatoes: 22, raised_bed: 23). Matches page 08’s table.sensor_channel— ADS1115 input the sensor wires to (0/1/2). Matches page 08’s sensor-to-ADC mapping.description— human-readable label for the log lines. No functional effect; tune for clarity.dry_threshold— the ADC value above which the script decides “this zone needs water.” HIGHER = drier. Set this from page 11’s calculator after taking your dry/wet readings.wet_threshold— the ADC value below which the script stops watering this cycle. The defaults in the script (12000,13000,12500) were calibrated against my potting mix; use page 11’s calibration calculator for yours.pump_seconds— how many seconds to run the pump per cycle when watering. Lettuce 12 (small zone, 3 emitters); tomatoes 35 (longest zone, 20 emitters); raised bed 20 (5 emitters). Scale roughly with emitter count.max_cycles— fail-safe: stop after N cycles even if the sensor doesn’t say “wet enough.” Default 4. Prevents runaway watering when a sensor is dead or an emitter is clogged.
If you have more or fewer than 3 zones, see “Adapting for more zones” below.
Other constants
SOAK_WAIT_SECONDS = 60— how long to wait between watering cycles for water to soak in before re-reading the sensor. 60s is a good default for potting mix; for raised beds with heavier soil, you may want 90-120.SENSOR_SAMPLES = 10— number of ADC reads averaged per sensor query. More samples = less noise. 10 is fine for clean wiring; bump to 20 if your readings jitter.SENSOR_SAMPLE_DELAY = 0.1— seconds between samples in a query. Together withSENSOR_SAMPLES, total query time is about 1 second per sensor.RELAY_ACTIVE_LOW = True— most opto-isolated relay modules trigger on LOW. If yours is active-high, flip this. (Most v1 readers leave itTrue.)VALVE_SETTLE_TIME = 0.5— seconds between opening the valve and starting the pump (so the valve has time to fully open before pressure hits it). 0.5s is plenty for hobby solenoids.
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:
- Read the sensor. Average
SENSOR_SAMPLES(10) ADC reads. If abovedry_threshold, the zone needs water; if below, log “soil is moist enough” and skip the zone. - Open the valve. Set the zone’s
valve_pinto LOW (active-low). WaitVALVE_SETTLE_TIME(0.5s) so the valve is fully open before pressure hits it. - Run the pump. Set
PUMP_PINto LOW forpump_seconds(12 / 35 / 20 depending on zone). - Stop the pump first. Set
PUMP_PINback to HIGH. WaitVALVE_SETTLE_TIME. Pump-off-before-valve-close avoids the small pressure spike you’d get from closing a valve against a running pump. - Close the valve. Restore the zone’s
valve_pinto HIGH. - Soak. Wait
SOAK_WAIT_SECONDS(60s) for water to distribute through the soil. - 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. - 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 30sPage 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.pyConfirm 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.