Automatización IoT

Sistema de riego automático con control MQTT: humedad del suelo, previsión y válvulas

Combina lecturas de humedad del suelo, una previsión de lluvia y válvulas controladas por MQTT en un sistema de riego automático que riega solo cuando hace falta y siempre se cierra de forma segura.

Sistema de riego automático con una sonda de humedad del suelo, una electroválvula en una línea de goteo y un controlador ESP32 conectado por MQTT
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Un sistema de riego automático riega solo cuando el suelo está realmente seco y no se espera lluvia, en lugar de funcionar con un temporizador aunque esté cayendo un chaparrón. En este tutorial conectarás lecturas de humedad del suelo, una previsión meteorológica gratuita y electroválvulas controladas por MQTT: un controlador en Python toma las decisiones y unos nodos de válvulas ESP32 las ejecutan con límites de seguridad estrictos, de modo que un mensaje perdido nunca pueda dejar el agua abierta.

Cómo encajan las piezas del sistema

 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)

Cada parte tiene una única tarea. Los sensores informan de la humedad. El controlador combina la humedad, la previsión y una ventana horaria. Los nodos de válvulas abren durante el tiempo solicitado y se cierran solos. Como todo pasa por el broker, puedes añadir zonas, sustituir el controlador por Node-RED o vigilarlo todo desde el móvil sin cambiar las demás partes. La guía de IoT para agricultura inteligente cubre el diseño general, y las lecturas de humedad de este artículo proceden del nodo construido en el tutorial de sensor de humedad del suelo MQTT.

Topics y payloads

Los ejemplos usan el servidor MQTT público gratuito. Sustituye i8r3c6 por tu propia cadena aleatoria; el área pública es compartida y cualquiera puede leerla.

Topic Payload Retenido
connectmq/demo/i8r3c6/irrigation/zone1/moisture {"moisture_pct":22.5} sí
connectmq/demo/i8r3c6/irrigation/zone1/set {"state":"open","duration_s":600} o {"state":"close"} no
connectmq/demo/i8r3c6/irrigation/zone1/state {"state":"open","remaining_s":540} sí
connectmq/demo/i8r3c6/irrigation/valves/status online / offline (last will) sí
connectmq/demo/i8r3c6/irrigation/log {"zone":1,"action":"skip","reason":"rain_forecast"} no

El comando siempre lleva una duración. Un simple "abrir" seguido más tarde de un "cerrar" independiente es frágil: si el mensaje de cierre se pierde, la zona se inunda. Con una duración, el nodo de válvulas se cierra por sí mismo.

Reglas de decisión para un sistema de riego automático

Escribe las reglas antes de escribir código. Un conjunto inicial práctico:

  1. Riega solo dentro de las ventanas permitidas (lo habitual es a primera hora de la mañana, cuando la evaporación y el viento son mínimos).
  2. Riega una zona solo si su humedad está por debajo del umbral de esa zona.
  3. Omite el riego si la previsión para hoy o mañana supera un límite de lluvia.
  4. Nunca riegues la misma zona dos veces dentro de un intervalo mínimo.
  5. Limita cada riego a una duración máxima, aplicada tanto por el controlador como por el nodo de válvulas.
  6. Si los datos de humedad tienen más de unas horas, no riegues automáticamente; lanza una alerta.

Los umbrales dependen del suelo y del cultivo. Empieza con un umbral conservador, observa lo rápido que se recupera la humedad tras un riego y ajusta la duración en lugar de la frecuencia.

El controlador en Python

El controlador usa paho-mqtt 2.x y la API de previsión gratuita de Open-Meteo, que no necesita clave. Instálalo con 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          # tu ubicación

ZONES = {
    1: {"threshold": 28.0, "duration_s": 600},
    2: {"threshold": 30.0, "duration_s": 420},
}
WINDOWS = [(5, 8), (19, 21)]     # horas locales, inicio incluido, fin excluido
RAIN_LIMIT_MM = 3.0
MIN_INTERVAL_S = 12 * 3600
STALE_S = 3 * 3600

moisture = {}                    # zona -> (valor, marca de tiempo)
last_run = {}                    # zona -> marca de tiempo
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()      # no repetir la alerta en cada ciclo
            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)

En esta versión sencilla las zonas se riegan de una en una solo porque se evalúan en secuencia; si tu suministro de agua no puede alimentar dos zonas a la vez, añade una comprobación de que ninguna otra zona está abierta antes de enviar el siguiente comando, usando los mensajes state retenidos.

El nodo de válvulas ESP32

El nodo de válvulas se suscribe a sus zonas, impone una duración máxima estricta, cierra todo si la conexión con el broker se pierde durante más de un minuto e informa del estado. Usa PubSubClient y 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;            // límite absoluto
const uint32_t OFFLINE_CLOSE_MS = 60000;

uint32_t closeAt[NUM_ZONES] = {0, 0};       // plazo en millis(), 0 = cerrada
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);        // cerrada al arrancar
  }
  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);
  }
}

Acciona las electroválvulas mediante un módulo de relés o una placa MOSFET dimensionada para la tensión y la corriente de la válvula, con un diodo de rueda libre en las electroválvulas de corriente continua. La mayoría de las válvulas de jardín son de 24 V AC; las válvulas biestables (de enclavamiento) que se usan en sistemas a batería necesitan un driver de pulsos en lugar de un relé mantenido activado. El artículo relé ESP32 con MQTT explica el cableado de relés.

Cómo llevar los datos de humedad al controlador

El controlador espera un mensaje retenido por zona en zone<n>/moisture. La forma más sencilla de conseguirlo es configurar directamente esa ruta como topic de telemetría del nodo de suelo. Si tus sensores ya publican en otro lugar, por ejemplo sondas LoRaWAN que llegan a través del puente de un servidor de red, unas pocas líneas en el puente pueden republicar el campo de humedad en el topic de la zona. Mantén la asignación de sensor a zona en un único lugar, idealmente la configuración del controlador, para que mover una sonda no obligue a reflashear nada.

Si una zona tiene dos sondas, promedia sus valores o riega según la más seca, pero registra ambas. Una sola sonda averiada que lea cero no debería poder activar el riego por sí misma; la regla de datos obsoletos cubre las sondas que dejan de informar, y un rango de validez (ignorar valores por debajo del 1 o por encima del 99 por ciento) cubre las que están rotas.

Elegir umbrales y tiempos de riego

El umbral indica cuándo regar; la duración, cuánto. Ajústalos en ese orden. Observa la curva de humedad durante una semana sin riego automático, anotando el valor al que las plantas empiezan a mostrar estrés, y fija el umbral unos puntos por encima. Después riega una zona manualmente durante un tiempo fijo y observa cuánto y con qué rapidez sube la humedad. Si la lectura salta y vuelve a bajar en pocas horas, el agua está drenando más allá de las raíces; son mejores riegos más cortos y frecuentes. Si apenas se mueve, el riego es demasiado corto o la sonda está fuera del patrón de goteo.

Registra cada cambio en el topic de log. A lo largo de una temporada, el log publicado en el broker se convierte en la prueba de qué ajustes funcionan, y una vista en directo como el explorador de topics de ConnectMQ permite ver el razonamiento del controlador en tiempo real.

Pruebas sin agua

Antes de conectar una manguera, prueba con LED en los pines de las válvulas:

# simular una lectura seca para la zona 1
mosquitto_pub -h broker.connectmq.com -p 1883 -i t-i8r3c6 -r \
  -t 'connectmq/demo/i8r3c6/irrigation/zone1/moisture' -m '{"moisture_pct":19}'

# abrir la zona 1 manualmente durante 20 segundos
mosquitto_pub -h broker.connectmq.com -p 1883 -i t-i8r3c6 \
  -t 'connectmq/demo/i8r3c6/irrigation/zone1/set' -m '{"state":"open","duration_s":20}'

# observarlo todo
mosquitto_sub -h broker.connectmq.com -p 1883 -i w-i8r3c6 \
  -t 'connectmq/demo/i8r3c6/irrigation/#' -v

Después quita la antena Wi-Fi del ESP32 o apaga el router durante un riego y confirma que la válvula se cierra en menos de un minuto. Esa sola prueba vale más que cualquier revisión de código.

Lista de comprobación de robustez

  • La válvula se cierra al arrancar, al expirar la duración, al perder el broker y ante cualquier comando mal formado.
  • Un caudalímetro en la línea principal confirma que el agua realmente circuló; publica los litros por riego y genera una alerta si hay caudal con todas las válvulas cerradas (una tubería rota).
  • Los comandos nunca se retienen; los estados, siempre.
  • Cada controlador y nodo de válvulas tiene sus propias credenciales en un broker privado con TLS.
  • Existe una anulación manual: un interruptor físico o un botón en el panel que publique {"state":"close"}.

Configuración para producción

Cambia el área pública de pruebas por un broker privado antes de conectar válvulas reales, porque cualquiera podría publicar en un topic set público. En el ESP32, usa WiFiClientSecure con la CA ISRG Root X1 en el puerto 8883 y mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...); en Python, llama a client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD") y client.tls_set() y conéctate al puerto 8883. Un broker MQTT gestionado mantiene el broker disponible aunque el PC de la finca se reinicie, y un broker MQTT en la nube permite que los nodos de campo con conexión móvil lleguen a él sin VPN. Para el control climático del invernadero junto al riego, consulta automatización de invernaderos, y para más patrones visita la guía de automatización IoT.

Riega de forma más inteligente con ConnectMQ

ConnectMQ proporciona a tu sistema de riego un broker MQTT privado con TLS, un explorador de topics en directo para ver cada comando y cada confirmación, y reglas de alerta por umbral para zonas secas o caudales inesperados. Crea un broker Serverless gratuito en app.connectmq.com, sin tarjeta de crédito, y traslada a él tu controlador y tus válvulas.

Preguntas frecuentes

¿Qué ocurre si la conexión MQTT se cae mientras una válvula está abierta?

El nodo de válvulas cierra todas las válvulas abiertas tras 60 segundos sin conexión con el broker, y cada riego tiene un límite de duración, así que el agua no puede correr indefinidamente.

¿Puedo usar Home Assistant o Node-RED en lugar del controlador en Python?

Sí. Funciona cualquier cosa que se suscriba a los topics de humedad y publique el mismo comando JSON; al nodo de válvulas no le importa quién lo envíe.

¿Debe decidir la previsión o el sensor de suelo?

Usa ambos. El sensor de suelo indica si hace falta agua ahora; la previsión, si la naturaleza la aportará pronto. Omite el riego solo cuando ambos coincidan.

¿Cuántas zonas puede controlar un ESP32?

Tantas como pines de salida y canales de relé libres tengas, normalmente de cuatro a ocho. Riega las zonas de una en una si la presión del agua no da para varias a la vez.

Artículos relacionados

Guía para configurar Zigbee2MQTT: del coordinador al panel

Instala Zigbee2MQTT con Docker, configura el coordinador y la conexión MQTT, empareja y renombra dispositivos, contrólalos por MQTT y consulta sus estados en un panel, probando primero en un servidor MQTT público gratuito.

8 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.