Estación meteorológica y sistema de riego autónomo con D1 Mini
21/03/2023
Descripción
Hoy mostraré este «pequeño» aunque a la vez uno de los más útiles montajes que he hecho nunca desde que me dedico a la «cosa maker». Se trata de una mini-estación meteorológica que transmite los datos de temperatura, humedad y presión atmosférica a un servidor MQTT, a la vez que gestiona el riego autónomo de una maceta en función de la humedad del suelo medida por un sensor llamado «higrómetro» activando una bomba de agua.
El higrómetro (de tipo capacitivo, más eficiente y con más duración que los de tipo resistivo) irá ubicado en la tierra de una maceta, como se puede ver en la foto, y el termómetro digital BME280 debería ubicarse en un sitio protegido de la lluvia y la humedad, a la sombra y a la vez expuesto al medio, para que las medidas sean correctas.
Esquema
En el esquema se puede observar que el conjunto recibe la alimentación de un módulo que nos proporciona pines con la misma corriente de entrada (12V), y a la vez nos proporciona pines de 3.3V y 5V, como puede verse en la imagen de la izquierda. Es un módulo muy interesante porque nos permite distribuir diferentes buses de corriente para alimentar a otros módulos con voltajes distintos. Por ejemplo, el relé se activará con una corriente de 5V (se pueden usar relés de otros voltajes, como 3.3V o 12V, pero hay que tener en cuenta que entonces habrá que conectarlo a la salida correspondiente de ese voltaje en el módulo alimentador), pero cerrará un circuito de 12V para que funcione la bomba de agua, y todo procederá del mismo módulo alimentador. El módulo de alimentación también proporcionará los 3.3V que necesita el termómetro digital BME280 y los 5V que necesita el medidor de humedad del suelo. Casi se podría decir que este módulo es la pieza más importante de todo el conjunto. Chulo, ¿verdad?
El módulo BME280 es de tipo I2C, y se conectará, como aparece en el esquema, a la alimentación de 3.3V y a los pines SDA (D2) y SDL (D1) del microcontrolador D1 Mini.
Por su parte, el higrómetro puede ser alimentado con 3.3V o 5V, y se conectará a la entrada analógica A0 del microcontrolador D1 Mini.
La bomba de agua (en mi caso es como la de la foto de la derecha, de 12V y 5W de potencia) se conectará con su polaridad correcta, obviamente, a la salida de 12V controlada por el relé. Este montaje está pensado para una bomba de agua pequeña. En caso de necesitar una bomba de agua de más potencia hay que tener en cuenta que el módulo alimentador no podrá suministrarle la energía necesaria, y habrá que tener también en cuenta las especificaciones del relé que instalemos, que puede ser un módulo de relé externo como el de la foto de la derecha. Con este tipo de relé podríamos alimentar una bomba de hasta 220V y 10A (cosa que recomiendo encarecidamente que no hagáis, por más que lo digan las especificaciones. Para estos casos, mejor instalar un contactor además del relé, que son caros, pero mucho más seguros).
Código
Aunque debido a lo extenso que es no voy a comentar todo el código, sí me voy a centrar en algunas partes del mismo que me parecen más importantes. En primer lugar, en la estructura de datos que grabaremos en la EEPROM del D1 Mini para que sean datos persistentes que no se pierdan en un reinicio o un corte de electricidad:
// EEPROM struct datosEeprom { int NTPOffset = 3600; int emitir_timer = 10; int conectar_timer = 60; // Datos específicos del controlador "palmerita" int tiempo_medida = 5; // Intervalo entre medidas 5 segundos int tiempo_riego = 10; // 10 segundos de riego int tiempo_intervalo_riego = 43200; // Cada 12 horas como mínimo int umbral_humedad = 350; // Umbral mínumo de humedad para efectuar el riego bool modo = true; // true: modo automático ; false: modo manual }; datosEeprom eepromData;
Entre estas variables hay que destacar «NTPOffset», que controla el horario de verano/invierno. Uno de mis bots en python emite por MQTT cada 15 minutos el offset horario correcto, y cuando éste cambia, más adelante veremos cómo se recibe la orden de modificar el NTPOffset y grabar de nuevo la EEPROM para que el cambio sea permanente. En este caso no es esencial que el controlador disponga de la hora correcta, pero son funciones que incluyo en cada uno de los sketches para tenerlas disponibles en caso necesario.
Las siguientes variables, «emitir_timer» y «conectar_timer» indican los intervalos entre transmisiones al servidor MQTT y entre verificaciones de la conexión al wifi. Los valores por defecto son 10 y 60 segundos, respectivamente.
«tiempo_medida», «tiempo_riego» y «tiempo_intervalo_riego» indican los intervalos para tomar medidas, el tiempo de riego efectivo en cada proceso de regado y el intervalo a esperar entre un proceso de riego y el siguiente. Por defecto, 5, 10 y 43.200 segundos (12 horas).
«umbral_humedad» indica el mínimo de humedad necesario para que se inicie un proceso de riego. Si la humedad tiene valores inferiores a 350 (más humedad), el proceso no se iniciará.
«modo» Es una variable booleana (true o false) para determinar si el sistema se encuentra en modo automático (riego autónomo) o en modo manual (riego según órdenes). Si el sistema se encuentra en modo manual, el sistema no regará aunque se rebase el umbral de humedad del suelo.
Y con esto terminan las variables que serán almacenadas en EEPROM para que la programación del sistema no se altere tras un reinicio o un corte de corriente.
La función «conectar()», como es habitual en mis sketches, no se queda esperando a tener una conexión wifi, sino que lo intenta diez veces como máximo cada minuto (o el tiempo definido en «eepromData.conectar_timer») para que el sistema pueda seguir funcionando incluso en el caso de que el wifi falle durante un tiempo.
int contador = 0; while (WiFi.status() != WL_CONNECTED && contador < 10) { conectado_wifi = false; Serial.print("Conectando a WiFi... "); Serial.println(contador); contador++; delay(800); }
Una vez conseguida la conexión wifi, se procederá a conectarse (en caso de haber quedado desconectado previamente) al servidor MQTT. También hacemos que este proceso tenga un número de intentos limitado para no caer en un bucle sin fin que bloquee el sistema.
// Iniciamos el proceso de intento de conexión a MQTT if (!client.connected() || !conectado_mqtt) { contador = 0; conectado_mqtt = false; while (!client.connect(clientID, mqtt_username, mqtt_password) && contador < 10) { Serial.print("Conectando a MQTT... "); Serial.println(contador); contador++; delay(500); } }
En cuanto a la gestión de las órdenes recibidas a través de MQTT, el dispositivo se suscribe a «casa/ordenes/#» ,de manera que recibe todas las órdenes que se transmitan a través de este topic a todos los dispositivos de la casa. Como he comentado antes, cada 15 minutos se actualiza el offset horario, que es transmitivo a través del topic «casa/ordenes/offset» y gracias al cual todos los dispositivos cambian el horario de verano o invierno según dicho offset.
Además, el dispositivo reaccionará a órdenes dirigidas sólo a él a través del topic «casa/ordenes/palmerita»:
* E - Graba la configuración actual en EEPROM * D - Devuelve los datos e configuración actuales * M - Cambia el modo de automático a manual * R - Ejecuta un ciclo de riego incondicional * T xx - Cambia el tiempo de riego * I xx - Cambia el intervalo de tiempo mínimo entre riegos * U xx - Cambia el umbral de humedad necesario para ejecutar el riego
El código completo del sketch (a falta de los datos de configuración de wifi y MQTT, es el siguiente:
#include <EEPROM.h> #include <ESP8266WiFi.h> #include <NTPClient.h> #include <WiFiUdp.h> #include <PubSubClient.h> #include <Adafruit_BME280.h> // EEPROM struct datosEeprom { int NTPOffset = 3600; int emitir_timer = 10; int conectar_timer = 60; // Datos específicos del controlador "palmerita" int tiempo_medida = 5; // Intervalo entre medidas 5 segundos int tiempo_riego = 10; // 10 segundos de riego int tiempo_intervalo_riego = 43200; // Cada 12 horas como mínimo int umbral_humedad = 350; // Umbral mínumo de humedad para efectuar el riego bool modo = true; // true: modo automático ; false: modo manual }; datosEeprom eepromData; // Datos de conexión WiFi char* ssid = "SSID_de_tu_wifi"; char* passwd = "password_de_tu_wifi"; WiFiClient wifiClient; bool conectado_wifi = false; time_t conectar_contador = 0; // Datos de conexión MQTT char* mqtt_server = "192.168.1.118"; // Dirección IP o URL del servidor MQTT int mqtt_port = 1883; char* mqtt_username = "usuario_de_tu_servidor_MQTT"; char* mqtt_password = "password_de_tu_servidor_MQTT"; char* topic_ordenes_general = "casa/ordenes/#"; char* clientID = "palmerita"; char* topic_respuesta = "casa/palmerita/respuesta"; char* topic_ordenes_especifico = "casa/ordenes/palmerita"; char* topic_temperatura = "casa/palmerita/temperatura"; char* topic_humedad = "casa/palmerita/humedad"; char* topic_presion = "casa/palmerita/presion"; char* topic_suelo = "casa/palmerita/suelo"; PubSubClient client(mqtt_server, mqtt_port, wifiClient); bool conectado_mqtt = false; time_t emitir_contador = 0; // Datos de conexión NTP const char* servidor_NTP = "192.168.1.118"; // Dirección IP o URL de un servidor NTP público o privado WiFiUDP ntpUDP; NTPClient timeClient(ntpUDP, servidor_NTP, eepromData.NTPOffset); time_t tiempoUNIX = 0; time_t tiempoUNIX_UTC = 0; struct tm fechaHora; // Termómetro digital BME Adafruit_BME280 bme; float temperatura = 0.0; float humedad = 0.0; float presion = 0.0; // Medidor de humedad del suelo int humedad_suelo = 0; // Pines de E/S int circuito_rele = D5; // Contadores time_t contador_medida = 0; time_t contador_riego = 0; time_t contador_intervalo_riego = 0; // Banderas de estado bool regando = false; // Graba los datos de la struct eepromData en memoria eeprom void grabarEeprom(datosEeprom d) { Serial.println("Iniciando grabación de EEPROM..."); EEPROM.begin(4096); EEPROM.put(0, d); EEPROM.end(); Serial.println("EEPROM grabada"); } // Lee los datos de la struct eepromData desde memoria eeprom datosEeprom leerEeprom() { datosEeprom d; Serial.println("Iniciando lectura de EEPROM..."); EEPROM.begin(4096); EEPROM.get(0, d); EEPROM.end(); Serial.println("EEPROM leída"); return d; } // Intenta conectar los servicios de WiFi, NTP y MQTT void conectar() { // Iniciamos el proceso de intento de conexión int contador = 0; while (WiFi.status() != WL_CONNECTED && contador < 10) { conectado_wifi = false; Serial.print("Conectando a WiFi... "); Serial.println(contador); contador++; delay(800); } // Finalizado el proceso, comprobamos el estado de la conexión if (WiFi.status() == WL_CONNECTED) { // Si venimos de estado desconectado, avisar de la nueva conexión if (!conectado_wifi) { Serial.println("Conectado con éxito a WiFi"); } conectado_wifi = true; // Iniciamos el proceso de intento de conexión a MQTT if (!client.connected() || !conectado_mqtt) { contador = 0; conectado_mqtt = false; while (!client.connect(clientID, mqtt_username, mqtt_password) && contador < 10) { Serial.print("Conectando a MQTT... "); Serial.println(contador); contador++; delay(500); } } if (client.connected()) { // Si venimos de estado desconectado, avisar de la nueva conexión if (!conectado_mqtt) { Serial.println("Conectado con éxito a MQTT"); } conectado_mqtt = true; client.subscribe(topic_ordenes_general); client.loop(); } else { Serial.println("***Error de conexión MQTT"); } } else { Serial.println("***Error de conexión WiFi"); } } // Emite un dato al servidor MQTT en el topic indicado void emitirDato(char* dato_topic, String payload, bool retain) { if (dato_topic == topic_respuesta) { payload = timeClient.getFormattedTime() + " - " + payload; } if (conectado_mqtt) { int contador = 0; conectar(); while (!client.publish(dato_topic, payload.c_str(), retain) && contador < 10) { delay(100); contador++; } } else { Serial.println("***Imposible transmitir datos: MQTT desconectado"); } emitir_contador = tiempoUNIX_UTC; } // Actualiza la hora void actualizarHora() { timeClient.update(); tiempoUNIX = timeClient.getEpochTime(); tiempoUNIX_UTC = tiempoUNIX - eepromData.NTPOffset; struct tm ts; ts = *localtime(&tiempoUNIX); fechaHora.tm_year = ts.tm_year + 1900; fechaHora.tm_mon = ts.tm_mon + 1; fechaHora.tm_mday = ts.tm_mday; fechaHora.tm_hour = ts.tm_hour; fechaHora.tm_min = ts.tm_min; fechaHora.tm_sec = ts.tm_sec; } // Gestiona las órdenes recibidas desde el servidor MQTT void gestionarOrdenes(String to, String st) { bool error = true; // El offset horario es transmitido por el servidor para todos los controladores if (to == "casa/ordenes/offset") { int nuevo_offset = st.toInt(); if (nuevo_offset != eepromData.NTPOffset && (nuevo_offset == 3600 || nuevo_offset == 7200)) { emitirDato(topic_respuesta, "Cambiando valor de offset horario a " + st, true); eepromData.NTPOffset = st.toInt(); timeClient.setTimeOffset(eepromData.NTPOffset); datosEeprom actualEeprom = eepromData; eepromData = leerEeprom(); eepromData.NTPOffset = actualEeprom.NTPOffset; grabarEeprom(eepromData); eepromData = actualEeprom; error = false; } } // Gestionar órdenes específicas para este controlador if (to == String(topic_ordenes_especifico)) { String orden = st; String respuesta = ""; String comando[3] = { "", "", "" }; int parametros = 0; int t = 0; if (orden != "") { for (int i = 0; i < orden.length() - 1; i++) { if (orden[i] == ' ') { comando[parametros] = orden.substring(t, i); t = i + 1; parametros++; if (parametros > 2) { break; } } } comando[parametros] = orden.substring(t, orden.length()); switch (parametros) { // Órdenes sin parámetro case 0: switch (comando[0][0]) { case 'E': grabarEeprom(eepromData); emitirDato(topic_respuesta, "Configuración grabada en EEPROM", true); error = false; break; case 'D': emitirDato(topic_respuesta, "Time offset: " + String(eepromData.NTPOffset) + " - Intervalo de emisión: " + String(eepromData.emitir_timer) + "s - Tiempo de riego: " + String(eepromData.tiempo_riego) + "s - Intervalo entre riegos: " + String(eepromData.tiempo_intervalo_riego/3600) + "h - Umbral de humedad: " + String(eepromData.umbral_humedad), true); error = false; break; case 'M': { eepromData.modo = !eepromData.modo; grabarEeprom(eepromData); String m = "auto"; if (!eepromData.modo) { m = "manual"; } emitirDato(topic_respuesta, "Modo de funcionamiento cambiado a "+m, true); error = false; break; } // Orden de lanzar riego. Funcionará en cualquiera de los modos independientemente de la humedad y de los temporizadores. case 'R': contador_intervalo_riego = tiempoUNIX + eepromData.tiempo_intervalo_riego; contador_riego = tiempoUNIX + eepromData.tiempo_riego; digitalWrite(circuito_rele, HIGH); Serial.println("Regando..."); regando = true; error = false; break; } break; // Órdenes con un parámetro case 1: int p1 = comando[1].toInt(); switch (comando[0][0]) { case 'T': if (p1 > 0 && p1 <= 20) { eepromData.tiempo_riego = p1; emitirDato(topic_respuesta, "Tiempo de riego cambiado a " + String(p1) + " segundos", true); grabarEeprom(eepromData); error = false; break; } case 'I': if (p1 > 0 && p1 <= 48) { eepromData.tiempo_intervalo_riego = p1 * 3600; emitirDato(topic_respuesta, "Intervalo entre riegos cambiado a " + String(p1) + " horas", true); grabarEeprom(eepromData); error = false; break; } case 'U': if (p1 > 0 && p1 <= 1024) { eepromData.umbral_humedad = p1; emitirDato(topic_respuesta, "Umbral de humedad cambiado a " + String(p1), true); grabarEeprom(eepromData); error = false; break; } } break; } } } if (error) { emitirDato(topic_respuesta, "Comando o parámetros incorrectos", true); } } // Función que gestiona la recepción de los topics suscritos void callback(char* topic, byte* payload, unsigned int length) { String to = String(topic); String st = ""; for (int i = 0; i < length; i++) { st = st + (char)payload[i]; } gestionarOrdenes(to, st); } // Mide datos del sensor BME y del higrómetro. Si se cumplen los requisitos, inicia el proceso de riego void medir() { temperatura = bme.readTemperature(); humedad = bme.readHumidity(); presion = bme.readPressure() / 100.0F; humedad_suelo = analogRead(A0); /* Condiciones para el inicio del riego: 1. Que la humedad del suelo supere el umbral 2. Que el contador de intervalo de riego haya vencido 3. Que no esté ya regando 4. Que el modo esté en auto (true) */ if (humedad_suelo > eepromData.umbral_humedad && contador_intervalo_riego < tiempoUNIX && !(regando) && eepromData.modo) { contador_intervalo_riego = tiempoUNIX + eepromData.tiempo_intervalo_riego; contador_riego = tiempoUNIX + eepromData.tiempo_riego; digitalWrite(circuito_rele, HIGH); Serial.println("Regando..."); regando = true; } } // Gestiona los temporizadores void actualizarContadores() { // Disparador del evento de conexión if (conectar_contador + eepromData.conectar_timer < tiempoUNIX_UTC) { conectar(); conectar_contador = tiempoUNIX_UTC; } // Disparador del evento de emitir datos if (emitir_contador + eepromData.emitir_timer < tiempoUNIX_UTC) { emitirDato(topic_respuesta, "Ok", true); // Emitimos los datos del termómetro y el higrómetro emitirDato(topic_temperatura, String(temperatura), true); emitirDato(topic_humedad, String(humedad), true); emitirDato(topic_presion, String(presion), true); emitirDato(topic_suelo, String(humedad_suelo), true); } // Disparador del evento de medida if (contador_medida < tiempoUNIX) { contador_medida = tiempoUNIX + eepromData.tiempo_medida; medir(); } // Disparador del evento de finalización de riego if (contador_riego < tiempoUNIX && regando) { contador_riego = 0; digitalWrite(circuito_rele, LOW); Serial.println("Riego finalizado"); regando = false; } } // Inicialización general del sketch void setup() { Serial.begin(9600); /* Hay que grabar la Eeprom por defecto al menos una vez al instalar este sketch, y luego comentar la orden para que no se reescriba la eeprom por defecto en cada reinicio. Por lo tanto, esta orden debe estar comentada en la subida final */ // grabarEeprom(eepromData); eepromData = leerEeprom(); WiFi.begin(ssid, passwd); client.setServer(mqtt_server, mqtt_port); client.setCallback(callback); conectar(); timeClient.begin(); actualizarHora(); // Datos específicos del controlador "palmerita" // Inicializamos el termómetro digital bme.begin(0x76); // Configuramos el pin del relé y lo ponemos en OFF pinMode(circuito_rele, OUTPUT); digitalWrite(circuito_rele, LOW); } // Bucle principal void loop() { client.subscribe(topic_ordenes_general); client.loop(); actualizarHora(); actualizarContadores(); delay(1000); }