An automatic irrigation system waters only when the soil is actually dry and rain is not on the way, instead of running on a timer through every shower. In this tutorial you will connect soil moisture readings, a free weather forecast and MQTT-controlled solenoid valves: a Python controller makes the decisions, and ESP32 valve nodes execute them with hard safety limits so a lost message can never leave the water running.
How the System Fits Together
Soil sensors ---- telemetry ---->+
|
Weather API --> Python controller (decides) <---- MQTT broker ----> Dashboard
| ^
zone/<n>/set ---+ |
v |
ESP32 valve node --- zone/<n>/state
(relay -> 24 V AC or 12 V DC solenoid)
Each part has a single job. Sensors report moisture. The controller combines moisture, the forecast and a schedule window. Valve nodes open for a requested duration and close themselves. Because everything goes through the broker, you can add zones, swap the controller for Node-RED or watch it all from a phone without changing the other parts. The smart agriculture IoT guide covers the wider design, and the moisture readings in this article come from the node built in the soil moisture sensor MQTT tutorial.
Topics and Payloads
The examples use the public test broker. Replace i8r3c6 with your own random string; the public area is shared and readable by everyone.
| Topic | Payload | Retained |
|---|---|---|
connectmq/demo/i8r3c6/irrigation/zone1/moisture |
{"moisture_pct":22.5} |
yes |
connectmq/demo/i8r3c6/irrigation/zone1/set |
{"state":"open","duration_s":600} or {"state":"close"} |
no |
connectmq/demo/i8r3c6/irrigation/zone1/state |
{"state":"open","remaining_s":540} |
yes |
connectmq/demo/i8r3c6/irrigation/valves/status |
online / offline (last will) |
yes |
connectmq/demo/i8r3c6/irrigation/log |
{"zone":1,"action":"skip","reason":"rain_forecast"} |
no |
The command always carries a duration. A plain "open" with a separate "close" later is fragile: if the close message is lost, the zone floods. With a duration, the valve node closes on its own.
Decision Rules for an Automatic Irrigation System
Write the rules down before writing code. A practical starting set:
- Water only inside allowed windows (early morning is usual, because evaporation and wind are lowest).
- Water a zone only if its moisture is below the zone's threshold.
- Skip if the forecast for today or tomorrow exceeds a rain limit.
- Never water the same zone twice within a minimum interval.
- Cap each run at a maximum duration, enforced both by the controller and the valve node.
- If moisture data is older than a few hours, do not water automatically; raise an alert instead.
Thresholds depend on soil and crop. Start with a conservative threshold, watch how quickly moisture recovers after a run, and adjust duration rather than frequency.
The Python Controller
The controller uses paho-mqtt 2.x and the free Open-Meteo forecast API, which needs no key. Install with pip install paho-mqtt.
import json
import time
import urllib.request
from datetime import datetime
import paho.mqtt.client as mqtt
BROKER = "broker.connectmq.com"
BASE = "connectmq/demo/i8r3c6/irrigation"
LAT, LON = 41.01, 28.97 # your location
ZONES = {
1: {"threshold": 28.0, "duration_s": 600},
2: {"threshold": 30.0, "duration_s": 420},
}
WINDOWS = [(5, 8), (19, 21)] # local hours, start inclusive, end exclusive
RAIN_LIMIT_MM = 3.0
MIN_INTERVAL_S = 12 * 3600
STALE_S = 3 * 3600
moisture = {} # zone -> (value, timestamp)
last_run = {} # zone -> timestamp
forecast_cache = {"ts": 0, "rain": None}
def get_rain_forecast_mm():
if time.time() - forecast_cache["ts"] < 3600:
return forecast_cache["rain"]
url = (f"https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}"
"&daily=precipitation_sum&forecast_days=2&timezone=auto")
try:
with urllib.request.urlopen(url, timeout=10) as r:
daily = json.load(r)["daily"]["precipitation_sum"]
rain = max(v or 0.0 for v in daily)
except Exception as exc:
print("forecast unavailable:", exc)
rain = None
forecast_cache.update(ts=time.time(), rain=rain)
return rain
def log(client, **fields):
client.publish(f"{BASE}/log", json.dumps(fields), qos=1)
print(fields)
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code)
client.subscribe(f"{BASE}/+/moisture", qos=1)
client.subscribe(f"{BASE}/+/state", qos=1)
def on_message(client, userdata, msg):
parts = msg.topic.split("/")
zone_name, kind = parts[-2], parts[-1]
if not zone_name.startswith("zone"):
return
zone = int(zone_name[4:])
data = json.loads(msg.payload)
if kind == "moisture":
moisture[zone] = (float(data["moisture_pct"]), time.time())
elif kind == "state":
print(f"zone {zone} state:", data)
def in_window(now):
return any(start <= now.hour < end for start, end in WINDOWS)
def evaluate(client):
now = datetime.now()
if not in_window(now):
return
rain = get_rain_forecast_mm()
for zone, cfg in ZONES.items():
if time.time() - last_run.get(zone, 0) < MIN_INTERVAL_S:
continue
value, ts = moisture.get(zone, (None, 0))
if value is None or time.time() - ts > STALE_S:
log(client, zone=zone, action="alert", reason="stale_moisture")
last_run[zone] = time.time() # do not repeat the alert every cycle
continue
if value >= cfg["threshold"]:
continue
if rain is not None and rain >= RAIN_LIMIT_MM:
log(client, zone=zone, action="skip", reason="rain_forecast", rain_mm=rain)
last_run[zone] = time.time()
continue
cmd = {"state": "open", "duration_s": cfg["duration_s"]}
client.publish(f"{BASE}/zone{zone}/set", json.dumps(cmd), qos=1)
last_run[zone] = time.time()
log(client, zone=zone, action="water", moisture=value, duration_s=cfg["duration_s"])
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="irrigation-ctl-i8r3c6")
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 1883, keepalive=60)
client.loop_start()
while True:
evaluate(client)
time.sleep(300)
Zones are watered one at a time in this simple version only because they are evaluated in sequence; if your water supply cannot feed two zones at once, add a check that no other zone is currently open before sending the next command, using the retained state messages.
The ESP32 Valve Node
The valve node subscribes to its zones, enforces a hard maximum duration, closes everything if the broker connection is lost for more than a minute, and reports state. It uses PubSubClient and ArduinoJson 7.
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const char* CLIENT_ID = "valves-i8r3c6";
const char* BASE = "connectmq/demo/i8r3c6/irrigation";
const int NUM_ZONES = 2;
const int VALVE_PIN[NUM_ZONES] = {26, 27};
const uint32_t MAX_RUN_S = 1800; // absolute cap
const uint32_t OFFLINE_CLOSE_MS = 60000;
uint32_t closeAt[NUM_ZONES] = {0, 0}; // millis() deadline, 0 = closed
uint32_t lastConnected = 0;
uint32_t lastReport = 0;
WiFiClient net;
PubSubClient mqtt(net);
void publishState(int z) {
char topic[96], buf[64];
snprintf(topic, sizeof(topic), "%s/zone%d/state", BASE, z + 1);
uint32_t rem = closeAt[z] ? (closeAt[z] - millis()) / 1000 : 0;
snprintf(buf, sizeof(buf), "{\"state\":\"%s\",\"remaining_s\":%lu}",
closeAt[z] ? "open" : "closed", (unsigned long)rem);
mqtt.publish(topic, buf, true);
}
void setValve(int z, bool open, uint32_t seconds) {
if (open) {
seconds = min(seconds, MAX_RUN_S);
closeAt[z] = millis() + seconds * 1000UL;
if (closeAt[z] == 0) closeAt[z] = 1;
} else {
closeAt[z] = 0;
}
digitalWrite(VALVE_PIN[z], open ? HIGH : LOW);
if (mqtt.connected()) publishState(z);
}
void onMessage(char* topic, byte* payload, unsigned int len) {
int z = -1;
for (int i = 0; i < NUM_ZONES; i++) {
char t[96];
snprintf(t, sizeof(t), "%s/zone%d/set", BASE, i + 1);
if (strcmp(topic, t) == 0) z = i;
}
if (z < 0) return;
JsonDocument doc;
if (deserializeJson(doc, payload, len)) return;
const char* state = doc["state"] | "close";
uint32_t dur = doc["duration_s"] | 0;
setValve(z, strcmp(state, "open") == 0 && dur > 0, dur);
}
void connectMqtt() {
char willTopic[96];
snprintf(willTopic, sizeof(willTopic), "%s/valves/status", BASE);
if (mqtt.connect(CLIENT_ID, willTopic, 1, true, "offline")) {
mqtt.publish(willTopic, "online", true);
for (int i = 0; i < NUM_ZONES; i++) {
char t[96];
snprintf(t, sizeof(t), "%s/zone%d/set", BASE, i + 1);
mqtt.subscribe(t, 1);
publishState(i);
}
}
}
void setup() {
for (int i = 0; i < NUM_ZONES; i++) {
pinMode(VALVE_PIN[i], OUTPUT);
digitalWrite(VALVE_PIN[i], LOW); // closed at boot
}
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onMessage);
}
void loop() {
if (WiFi.status() == WL_CONNECTED && !mqtt.connected()) {
static uint32_t lastTry = 0;
if (millis() - lastTry > 5000) { lastTry = millis(); connectMqtt(); }
}
if (mqtt.connected()) lastConnected = millis();
mqtt.loop();
for (int i = 0; i < NUM_ZONES; i++) {
if (closeAt[i] && (int32_t)(millis() - closeAt[i]) >= 0) setValve(i, false, 0);
if (closeAt[i] && millis() - lastConnected > OFFLINE_CLOSE_MS) setValve(i, false, 0);
}
if (mqtt.connected() && millis() - lastReport > 30000) {
lastReport = millis();
for (int i = 0; i < NUM_ZONES; i++) if (closeAt[i]) publishState(i);
}
}
Drive the solenoids through a relay module or MOSFET board rated for the valve's voltage and current, with a flyback diode on DC solenoids. Most garden valves are 24 V AC; latching (bistable) valves used on battery systems need a pulse driver rather than a relay held on. The ESP32 relay MQTT article covers relay wiring.
Feeding Moisture Data into the Controller
The controller expects one retained message per zone on zone<n>/moisture. The simplest way to get there is to set the soil node's telemetry topic to that path directly. If your sensors already publish elsewhere, for example LoRaWAN probes arriving through a network server bridge, a few lines in the bridge can republish the moisture field to the zone topic. Keep the mapping of sensor to zone in one place, ideally the controller's configuration, so moving a probe does not require reflashing anything.
Where a zone has two probes, average them or water on the drier one, but log both. A single faulty probe reading zero should not be able to trigger watering on its own; the stale-data rule covers silent probes, and a sanity range (ignore values below 1 or above 99 percent) covers broken ones.
Choosing Thresholds and Run Times
The threshold says when to water; the duration says how much. Tune them in that order. Watch the moisture curve for a week without automatic watering, noting the value at which plants start to look stressed, and set the threshold a few points above it. Then run a zone manually for a fixed time and see how far and how fast moisture rises. If the reading jumps and then falls back within hours, water is draining past the roots; shorter, more frequent runs are better. If it barely moves, the run is too short or the probe sits outside the drip pattern.
Record each change in the log topic. Over a season, the log published to the broker becomes the evidence for which settings work, and a live view such as the ConnectMQ topic explorer makes it easy to see the controller's reasoning as it happens.
Testing Without Water
Before connecting a hose, test with LEDs on the valve pins:
# fake a dry reading for zone 1
mosquitto_pub -h broker.connectmq.com -p 1883 -i t-i8r3c6 -r \
-t 'connectmq/demo/i8r3c6/irrigation/zone1/moisture' -m '{"moisture_pct":19}'
# open zone 1 for 20 seconds by hand
mosquitto_pub -h broker.connectmq.com -p 1883 -i t-i8r3c6 \
-t 'connectmq/demo/i8r3c6/irrigation/zone1/set' -m '{"state":"open","duration_s":20}'
# watch everything
mosquitto_sub -h broker.connectmq.com -p 1883 -i w-i8r3c6 \
-t 'connectmq/demo/i8r3c6/irrigation/#' -v
Then pull the ESP32's Wi-Fi antenna or switch off the router during a run and confirm the valve closes within a minute. That single test is worth more than any amount of code review.
Hardening Checklist
- Valve closes on boot, on duration expiry, on broker loss and on any malformed command.
- A flow meter on the main line confirms water actually moved; publish litres per run and alert on flow when all valves are closed (a burst pipe).
- Commands are never retained, states always are.
- Each controller and valve node has its own credentials on a private broker with TLS.
- A manual override exists: a physical switch or a dashboard button that publishes
{"state":"close"}.
Production Setup
Swap the public test area for a private broker before connecting real valves, because anyone could publish to a public set topic. On the ESP32, use WiFiClientSecure with the ISRG Root X1 CA on port 8883 and mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...); in Python, call client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD") and client.tls_set() and connect to port 8883. A managed MQTT broker keeps the broker available even when the farm PC reboots, and a cloud MQTT broker lets field nodes on cellular links reach it without VPNs. For greenhouse climate control alongside watering, see greenhouse automation, and for more patterns visit the IoT automation guide.
Water Smarter with ConnectMQ
ConnectMQ gives your irrigation system a private MQTT broker with TLS, a live topic explorer to watch every command and confirmation, and threshold alert rules for dry zones or unexpected flow. Create a free Serverless broker at app.connectmq.com, no credit card required, and move your controller and valves onto it.
Frequently Asked Questions
What happens if the MQTT connection drops while a valve is open?
The valve node closes every open valve after 60 seconds without a broker connection, and every run has a duration cap, so water cannot run indefinitely.
Can I use Home Assistant or Node-RED instead of the Python controller?
Yes. Anything that subscribes to the moisture topics and publishes the same JSON command works; the valve node does not care who sent it.
Should the forecast or the soil sensor decide?
Use both. The soil sensor says whether water is needed now, the forecast says whether nature will provide it soon. Skip watering only when both agree.
How many zones can one ESP32 control?
As many as you have free output pins and relay channels, typically four to eight. Run zones one at a time if your water pressure cannot supply several at once.



