Relé controlado por ESP-01 a 12V

04/11/2022

Aquí traigo mi último trabajito de cacharreo. En principio lo quiero utilizar para controlar remotamente la bomba de agua del huerto hidropónico, de manera que pueda programarle los horarios y los tiempos de encendido, pero con pequeños cambios en el programa, este aparato podría controlar cualquier dispositivo entre 12V y los 5V. Aunque el regulador de tensión LD1117 admite una entrada de hasta 15V, la temperatura que alcanza incluso con el disipador atornillado me hace pensar que no sería sensato someterlo a más de 12V de tensión.

El regulador de tensión proporciona los 3.3V necesarios para el funcionamiento del microcontrolador ESP-01, uno de los más simples y baratos microcontroladores de la gama ESP8266, que he elegido para este montaje precisamente por su simplicidad, por su precio, porque el circuito no requiere de nada más complejo y, en fin, porque tenía unos cuantos de estos y hay que ir dándoles salida.

Diseño

Para este montaje me he propuesto utilizar componentes reciclados de otros circuitos de prueba que he ido haciendo a lo largo del tiempo y que han caído en desuso, y en el caso del relé, directamente de circuitos comerciales recogidos de la basura (es increíble lo que llegamos a tirar a la basura, amigos). También la placa de prototipado es reciclada, lo que se puede apreciar fácilmente en la fotografía adjunta. No es un trabajo muy fino que digamos, pero ¡eh! funciona y no ha salido ardiendo… todavía.

Como no soy demasiado hábil con Fritzing, he intentado disponer los componentes de manera que se vean lo mejor posible las conexiones, así que el esquema se parece al circuito real como un huevo a una castaña, pero básicamente es el mismo montaje.

Como la salida del pin 2 del ESP-01 sólo ofrece unos 20mA, es necesario el transistor NPN del esquema para poder activar el relé, que necesita unos 66mA. De esta forma, además, obtenemos la corriente del relé directamente de la salida del regulador, usando el pin 2 del ESP-01 para abrir o cerrar el circuito del relé.

Código

Pero ahora olvidemos por un rato la parte electrónica para pasar al código, porque estos cacharros, si no se programan, no sirven para nada. En este caso he querido ser un poco ambicioso, y poder controlar cada aspecto del funcionamiento del aparato sin necesidad de tener que andar desmontando y recompilando código, así que me he servido del servidor MQTT (Todo el mundo debería tener en casa un servidor MQTT) que tengo instalado en mi raspberry, en el que recibo información de varios sensores instalados en otros dispositivos caseros. Esta información la podéis ver en el bot @HispaBot_Meteo, que publica cada día la gráfica de temperatura, humedad, presión atmosférica y nivel de humedad del suelo en una maceta:

Como veis, todo esto son datos recogidos por el servidor MQTT y que otro programa a su vez se encarga de almacenar en una base de datos, para que luego puedan ser publicados en Mastodon. Bien, pues con nuestro aparatito vamos a hacer algo parecido, aunque no tengamos necesidad de almacenar esos datos: vamos a utilizar el servidor MQTT para conocer el estado de funcionamiento del aparato y además para poder enviarle órdenes con las que cambiar su comportamiento (como por ejemplo, las horas de funcionamiento, el tiempo de funcionamiento durante cada uno de los tramos horarios y cualquier otra cosa que se nos vaya ocurriendo.

El sketch, escrito con Arduino-IDE, es el siguiente:

#include <ESP8266WiFi.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <PubSubClient.h>
#include <EEPROM.h>
 
struct datosEeprom {
  int     NTPOffset         = 3600;
  int     hora_inicio       = 7;
  int     hora_final        = 23;
  int     minuto_inicio     = 0;
  int     minuto_final      = 30;
  int     emitir_timer      = 10;
};
 
datosEeprom eepromData;
 
const char* ssid = "SSID de tu red WiFi";
const char* passwd = "Password de tu red WiFi";
const char* mqtt_server = "Dirección de tu servidor MQTT";
int mqtt_port = 1883; // Puerto de tu servidor MQTT (puede ser otro)
const char* mqtt_username = "Usuario de tu servidor MQTT";
const char* mqtt_password = "Password de tu servidor MQTT";
const char* clientID = "huerto"; // Conviene que cada dispositivo tenga un clientID distinto
const char* topic_ordenes = "casa/ordenes/#";
char* topic_respuesta = "casa/huerto/respuesta";
char* topic_motor = "casa/huerto/motor";
const char* servidor_NTP = "Dirección de tu servidor NTP";
WiFiClient wifiClient;
PubSubClient client(mqtt_server, mqtt_port, wifiClient);
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, servidor_NTP, eepromData.NTPOffset);
bool conectado_wifi = false;
bool conectado_mqtt = false;
time_t tiempoUNIX = 0;
time_t tiempoUNIX_UTC = 0;
struct tm fechaHora;
int pin_motor = 2;
bool estado_motor = false;
time_t emitir_contador   = 0;
 
// Graba los datos de la struct eepromData en memoria eeprom
void grabarEeprom() {
  Serial.println("Iniciando grabación de EEPROM...");
  EEPROM.begin(4096);
  EEPROM.put(0, eepromData);
  EEPROM.end();
  Serial.println("EEPROM grabada");
}
// Lee los datos de la struct eepromData desde memoria eeprom
void leerEeprom() {
  Serial.println("Iniciando lectura de EEPROM...");
  EEPROM.begin(4096);
  EEPROM.get(0, eepromData);
  EEPROM.end();
  Serial.println("EEPROM leída");
}
 
// 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;
    conectado_mqtt = false;          
    Serial.print("Conectando a WiFi... ");
    Serial.println(contador);
    contador++;
    delay(500);
  }
  // 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);
      client.loop();
    }
    else {
      Serial.println("***Error de conexión MQTT");      
    }
  }
  else {
    Serial.println("***Error de conexión WiFi");
  }
}
 
void callback(char* topic, byte* payload, unsigned int length) {
  bool error = true;  
  String to = String(topic);
  String st = "";
  for (int i = 0; i < length; i++) {
    st = st + (char)payload[i];
  }
  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;
      leerEeprom();           
      eepromData.NTPOffset = actualEeprom.NTPOffset;
      grabarEeprom();
      eepromData = actualEeprom;      
      error = false;      
    } 
  }
  if (to == "casa/ordenes/huerto") {
    String orden = st;
    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) {
        case 0:
          switch (comando[0][0]) {
            case 'E':
              grabarEeprom();
              emitirDato(topic_respuesta, "Configuración grabada en EEPROM", true);
              error = false;
              break;
            case 'D':
              emitirDato(topic_respuesta,"Ho_Inic: "+String(eepromData.hora_inicio)+" - Ho_Fin: "+String(eepromData.hora_final)+" - Min_Inic: "+String(eepromData.minuto_inicio)+" - Min_Fin: "+String(eepromData.minuto_final), true);
              error = false;
              break;
          }
          break;          
        case 1:
          int p1 = comando[1].toInt();
          switch (comando[0][0]) {          
            case 'h':
              if (p1>=0 && p1<=22) {
                eepromData.hora_inicio = p1;
                emitirDato(topic_respuesta, "Hora de inicio cambiada a las "+comando[1], true);
                error = false;
              }
              break;
            case 'H':
              if (p1>=1 && p1<=23) {
                eepromData.hora_final = p1;
                emitirDato(topic_respuesta, "Hora de final cambiada a las "+comando[1], true);
                error = false;
              }
              break;
            case 'm':
              if (p1>=0 && p1<=58) {
                eepromData.minuto_inicio = p1;
                emitirDato(topic_respuesta, "Minuto de inicio cambiado a "+comando[1], true);
                error = false;
              }
              break;
            case 'M':
              if (p1>=1 && p1<=59) {
                eepromData.minuto_final = p1;
                emitirDato(topic_respuesta, "Minuto de final cambiado a "+comando[1], true);
                error = false;
              }
              break;
          }
          break; 
      }
    }
  }
  if (error) {
    emitirDato(topic_respuesta, "Comando o parámetros incorrectos", true);
  }
  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;
}
 
void emitirDato(char* dato_topic, String payload, bool retain) {
  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");
  }
}
 
void emitirHora() {
  emitirDato(topic_respuesta, "Hora: "+timeClient.getFormattedTime(), true);
}
 
void activarMotor(bool b) {
  if (b) {
    digitalWrite(pin_motor, HIGH);
    estado_motor = true;    
    emitirDato(topic_motor, "ON", true);      
  }
  else {
    digitalWrite(pin_motor, LOW);
    estado_motor = false;
    emitirDato(topic_motor, "OFF", true);  
  }
}
 
void controlMotor() {
  // Disparador del evento motor
  // Primero comprobamos que estamos en el tramo horario de funcionamiento y en el intervalo de minutos de funcionamiento
  if (fechaHora.tm_hour >= eepromData.hora_inicio && fechaHora.tm_hour <= eepromData.hora_final && fechaHora.tm_min >= eepromData.minuto_inicio && fechaHora.tm_min < eepromData.minuto_final) {
    estado_motor = true;
  }
  else {
    estado_motor = false;      
  }
  activarMotor(estado_motor);
}
 
void actualizarContadores() {
  // Disparador del evento de emitir datos
  if (emitir_contador + eepromData.emitir_timer < tiempoUNIX_UTC) {
    emitir_contador = tiempoUNIX_UTC;
    emitirHora();       
    controlMotor(); 
  }
}
 
void setup() {
  Serial.begin(9600);
  delay(2000);
  // grabarEeprom(); // Hay que grabar la EEPROM al menos la primera vez que se graba el sketch, para que luego tenga algo que leer.
  leerEeprom();
  WiFi.begin(ssid, passwd);
  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);
  conectar();
  timeClient.begin();
  pinMode(pin_motor, OUTPUT);
  digitalWrite(pin_motor, HIGH);  
}
 
void loop() {
  conectar();
  actualizarHora();
  actualizarContadores();
  delay(100);
}

Lógicamente, para poder utilizar este dispositivo será necesario disponer de Wifi, de un servidor MQTT y de un servidor NTP, pero no es difícil instalar estos servidores y hay montones de tutoriales en Internet donde aprender a instalarlos y configurarlos.

En el caso de que el dispositivo no pueda conectarse a la red WiFi o al servidor MQTT, seguirá intentándolo, pero al mismo tiempo continuará obedeciendo a su programación, ya sea la original o a los últimos cambios que se le hayan enviado. De esta forma puede ser «independiente» de la red y seguir haciendo su trabajo a pesar de que algún fallo lo haya dejado incomunicado.

Finalmente, para poder enviar comandos a este dispositivo y recibir las respuestas, se puede utilizar un programa como MQTT Explorer, para el escritorio del PC, o bien «IoT MQTT Panel» para dispositivos Android.

Set de comandos

Los siguientes comandos deben enviarse a través del topic «casa/ordenes/huerto»:

D – Devuelve la programación actual del dispositivo

E – Graba en EEPROM la programación actual

h XX – Fija la hora de inicio de funcionamiento del dispositivo (0 – 22 horas)

H XX – Fija la hora de final de funcionamiento del dispositivo (1 – 23 horas)

m XX – Fija el minuto de inicio de funcionamiento para cada hora (0 – 58 minutos)

M XX – Fija el minuto de final de funcionamiento para cada hora (1 – 59 minutos)

Para cambiar el offset horario (horario de invierno / horario de verano), se envía el offset a través del topic «casa/ordenes/offset», que admite los valores 3600 (horario de invierno, UTC+1) y 7200 (horario de verano, UTC+2). Para otros usos horarios se pueden cambiar estos valores en el propio sketch.

Actualización 05/05/2023

Hace poco recibí este pequeño dispositivo, muy parecido al que expongo en esta entrada, que permite controlar un relé con un microcontrolador ESP-01:

Este dispositivo tiene la ventaja de ser muy compacto, de pequeño tamaño y con un relé optoacoplado de hasta 10A y 250V con posiciones «normally open» y «normally closed», además de tener un precio ridículamente bajo. Por contra, necesita una alimentación de 5V en corriente continua independiente del circuito que abrirá o cerrará el relé.