Saltar al contenido
Nivel avanzado

Monitorear los tanques con Internet de las Cosas

Introducción a Internet de las Cosas (IoT)

Internet de las Cosas (en inglés Internet of Things, abreviado IoT) es un concepto que refiere a la interconexión digital de objetos cotidianos con internet. Esta interconexión puede tener diversas funciones. Por ejemplo, puede utilizarse para monitorear la temperatura de un ambiente, enviando los datos obtenidos por un sensor a una central donde se recopile la información. De esta manera podría visualizarse en un dispositivo móvil la temperatura de un laboratorio, de un invernadero o de una sala de un hospital.

Para poder incorporar IoT a nuestro proyecto es necesario:

  1. Un dispositivo capaz de conectarse a internet.
  2. Un servidor que reciba y aloje los datos.

Existen diversas formas de lograr el cometido de registrar y almacenar los datos del sistema de tanques construido. En este caso, se detallará cómo hacerlo con el WiFi integrado de la placa ESP32 y con los servidores de Adafruit IO.

El material original de 2019 resolvía este punto agregando al Arduino UNO un módulo externo llamado OBLOQ, un adaptador WiFi-serie de DFRobot hoy discontinuado (igual que su biblioteca ObloqAdafruit). Como el UNO clásico no tiene conectividad propia, en esta reedición la práctica de IoT se realiza con la variante ESP32 del proyecto: podés seguir este nivel de manera conceptual con tu UNO (los pasos 2 y 3 funcionan perfectamente en él) y cambiar al ESP32 con el selector de plataforma para la parte práctica de conexión, que es idéntica en lo didáctico y más simple en el circuito.

El ESP32 cumple los dos roles del “dispositivo capaz de conectarse a internet”: es el microcontrolador que ya está midiendo los tanques y manejando la válvula y la bomba, y, a la vez, trae radio WiFi integrada. No hace falta ningún módulo adicional ni cableado extra: la conexión a internet se resuelve por completo desde el código.

Crear un panel de control en Adafruit IO

En primer lugar, se explicará cómo crear un panel de control en Adafruit IO, una plataforma online con posibilidad de uso gratuito, orientada al uso educativo, que se encarga de centralizar la información que reportan los dispositivos. Luego, se verá cómo vincular los controles del panel con los datos que se intercambian con el dispositivo. Primero debemos crear una cuenta de usuario en io.adafruit.com.

Una vez que ingresamos con nuestro usuario, el trabajo tiene dos partes: crear los feeds donde se publicarán los datos y armar el panel que los muestre.

Un feed es una fuente de datos en la que uno puede publicar, así como también se puede suscribir para recibir los datos de cierto feed. Desde la sección Feeds creamos cuatro, con estos nombres exactos (sin tildes, porque forman parte de la dirección con la que se publica):

  • tanque1 y tanque2: recibirán el nivel de cada tanque. Lo que se publica es la distancia en centímetros que mide cada sensor entre la tapa y el agua, así que vale la lógica que ya conocés: a menor valor, tanque más lleno.
  • valvula y bomba: recibirán el estado de cada actuador, con la convención 1 = encendida/abierta y 0 = apagada/cerrada.

Luego, desde la sección Dashboards, creamos un nuevo panel con el nombre y la descripción que quieras. Dentro del panel agregamos cuatro bloques, uno por feed:

  • Dos bloques Gauge (indicador de aguja), asociados a tanque1 y tanque2, para mostrar el nivel de cada tanque. Como valor máximo del Gauge poné la profundidad de tu tanque en centímetros: la aguja cerca de cero significará tanque lleno (el agua pegada al sensor) y cerca del máximo, tanque vacío.
  • Dos bloques Indicator (luz virtual), asociados a valvula y bomba, con la condición “igual a 1”: encendidos cuando la válvula está abierta o la bomba trabajando, apagados cuando no.

Cada bloque permite elegir un título y sus colores, y luego se puede ajustar el tamaño y la ubicación de los bloques dentro del panel. Al finalizar, el panel replicará a distancia el estado completo del sistema: cuánta agua hay en cada tanque y qué están haciendo la válvula y la bomba.

Por último, necesitamos las credenciales que le permitirán a nuestro dispositivo publicar en la cuenta: en Adafruit IO, tocá el ícono de la llave (API Key). Vas a ver dos datos, el nombre de usuario (Username) y la clave (Active Key): anotalos, porque los vamos a copiar dentro del código en el paso 4. La clave es secreta: cualquiera que la tenga puede publicar en tus feeds.

Programar sin código bloqueante

Antes de comenzar a utilizar IoT debemos hacer una aclaración con respecto a la función delay() que figura en el código que usamos hasta ahora (nuestro programa espera un segundo entre medición y medición). Esta función brinda un tiempo de espera al sistema que puede utilizarse con varios fines. Suele utilizarse bastante en las primeras aproximaciones a la programación, ya que su comportamiento resulta fácil de comprender y su programación no requiere más que una línea de código.

Sin embargo, esta función tiene una complicación, dado que genera un código bloqueante. Esto significa que, cuando el programa entra en esa función, se detiene todo el procesamiento hasta que se cumpla el tiempo indicado. En otras palabras, cuando el programa entra al delay() queda “colgado” por el período de tiempo establecido.

Al utilizar IoT, es conflictivo utilizar código bloqueante, ya que al detenerse el procesamiento se impide también que el sistema realice otras operaciones que funcionan en simultáneo. Por ejemplo, las tareas de publicación y el mantenimiento constante de la conexión a internet.

Para evitar estos problemas, se puede utilizar una alternativa de código no bloqueante, como la función millis(). Esta función arroja un valor sobre un conteo de tiempo, que se realiza desde el momento en que se inicia el sistema. Es decir, funciona como un cronómetro (en milisegundos) que, cada vez que es consultada desde el código, “devuelve” el valor en el que se encuentra. De esta manera podemos pedirle al sistema que informe cuánto tiempo transcurrió desde el inicio de las operaciones para dar indicaciones temporales sobre una tarea, sin detener todas las demás.

La receta tiene tres ingredientes, que vas a reconocer en el código: una variable millisAnterior, que guarda cuándo fue la última medición (arranca en cero); la consulta del valor actual con millis() en cada vuelta de loop(); y la comparación entre ambos. Si la diferencia alcanzó el intervalo de medición, el programa mide los tanques y decide sobre la válvula y la bomba, y anota en millisAnterior el nuevo valor, para que la cuenta arranque de cero otra vez. Si todavía no pasó el tiempo, no espera: sigue de largo, con el procesador libre para otras tareas.

Así queda el programa de los dos tanques del nivel intermedio quitando el código bloqueante. De paso adopta el ritmo del programa IoT del original: se mide cada 3 segundos en lugar de cada 1 —el agua se mueve despacio y medir más seguido no aporta nada—:

Ver el código completo · dos-tanques-no-bloqueante.ino
dos-tanques-no-bloqueante.ino Arduino UNO
// Sistema de dos tanques SIN delay(): la misma lógica del nivel intermedio
// (válvula de la cisterna + bomba al tanque elevado), pero no bloqueante.
// En vez de detener el programa entre mediciones, cada vuelta de loop()
// pregunta con millis() si ya pasaron 3 segundos desde la última medición;
// el resto del tiempo el procesador queda libre para otras tareas (esta es
// la base que en ESP32 permite sumar IoT en tanques-iot.ino).
//
// SEGURIDAD: agua y electricidad no se llevan bien. Todas las conexiones
// eléctricas van fuera y POR ENCIMA del nivel del agua, los sensores no
// se deben mojar y la bomba sumergible solo se enciende estando bajo el
// agua. La práctica se realiza con válvula y bomba de baja tensión (12 V);
// la conexión a 220 V de la guía original queda reservada al docente o a
// una instalación supervisada, y nunca sobre la protoboard (desvío
// pedagógico acordado).
//
// Plataforma: Arduino UNO.
// Conexión (la del esquema original): sensor cisterna TRIG 8 / ECHO 9,
// relé de la válvula → pin 4; sensor tanque elevado TRIG 10 / ECHO 11,
// relé de la bomba → pin 5.
//
// Basado en: Fragmentos de código 3 (patrón millis) y 4 (medición y
// control cada 3 segundos, sin la parte IoT) — Sistema de Tanques,
// Saberes Digitales (INET/EDUCAR 2019)
// Modernización: (1) constantes nombradas; millisAnterior como unsigned
// long (el long del original desborda la cuenta). (2) Histéresis de 5 cm
// en los umbrales para que el ruido del HC-SR04 no haga traquetear los
// relés (el original conmutaba en el valor exacto).

// Sensor del tanque 1 (cisterna) y relé de la electroválvula.
const int PIN_TRIG1        = 8;
const int PIN_ECHO1        = 9;
const int PIN_RELE_VALVULA = 4;

// Sensor del tanque 2 (elevado) y relé de la bomba sumergible.
const int PIN_TRIG2      = 10;
const int PIN_ECHO2      = 11;
const int PIN_RELE_BOMBA = 5;

// Módulos relé activo-alto, como en el original (HIGH = actuador andando).
// Si tu módulo es activo-bajo, invertí solo estas dos constantes.
const int RELE_ENCENDIDO = HIGH;
const int RELE_APAGADO   = LOW;

// Distancias del ejemplo original (se ajustan midiendo en TU maqueta).
const float DISTANCIA_MINIMA1_CM = 40.0;  // cisterna llena
const float DISTANCIA_MAXIMA1_CM = 140.0; // cisterna casi vacía (bomba al aire)
const float DISTANCIA_MINIMA2_CM = 40.0;  // tanque elevado lleno

// Colchón anti-traqueteo (modernización, ver encabezado).
const float MARGEN_HISTERESIS_CM = 5.0;

// El Fragmento de código 4 del original mide cada 3 segundos: el agua se
// mueve despacio y medir más seguido no aporta nada.
const unsigned long INTERVALO_MEDICION_MS = 3000;

// 58 microsegundos de eco por cada cm; timeout para ecos que no llegan.
const float MICROSEGUNDOS_POR_CM = 58.0;
const unsigned long TIMEOUT_ECO_US = 30000;

// Momento (ms desde el arranque) de la última medición.
unsigned long millisAnterior = 0;

// Mide la distancia en cm entre un sensor y la superficie del agua.
// Si no llega eco, pulseIn devuelve 0 y la distancia da 0: se interpreta
// como "tanque lleno" y todo se apaga. Falla segura.
// (pulseIn puede esperar el eco hasta 30 ms; junto con los microsegundos
// del disparo, es la única espera que queda en el programa, y es parte
// de la medición misma, no un delay() de relleno.)
float medirDistanciaCm(int pinTrig, int pinEcho) {
  digitalWrite(pinTrig, LOW);
  delayMicroseconds(2);
  digitalWrite(pinTrig, HIGH); // pulso de disparo de 10 microsegundos
  delayMicroseconds(10);
  digitalWrite(pinTrig, LOW);
  return pulseIn(pinEcho, HIGH, TIMEOUT_ECO_US) / MICROSEGUNDOS_POR_CM;
}

void setup() {
  pinMode(PIN_TRIG1, OUTPUT);
  pinMode(PIN_ECHO1, INPUT);
  pinMode(PIN_TRIG2, OUTPUT);
  pinMode(PIN_ECHO2, INPUT);
  pinMode(PIN_RELE_VALVULA, OUTPUT);
  pinMode(PIN_RELE_BOMBA, OUTPUT);

  // Estado inicial seguro: válvula cerrada y bomba apagada hasta que
  // las primeras mediciones digan otra cosa.
  digitalWrite(PIN_RELE_VALVULA, RELE_APAGADO);
  digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
}

void loop() {
  unsigned long millisActual = millis();

  // ¿Ya pasaron 3 segundos desde la última medición? Si no, no hacemos
  // nada: eso es NO bloquear.
  if (millisActual - millisAnterior >= INTERVALO_MEDICION_MS) {
    millisAnterior = millisActual;

    float distancia1 = medirDistanciaCm(PIN_TRIG1, PIN_ECHO1); // cisterna
    float distancia2 = medirDistanciaCm(PIN_TRIG2, PIN_ECHO2); // elevado

    // --- Válvula de la cisterna ---
    if (distancia1 < DISTANCIA_MINIMA1_CM) {
      // Cisterna llena: cerrar la válvula.
      digitalWrite(PIN_RELE_VALVULA, RELE_APAGADO);
    } else if (distancia1 > DISTANCIA_MINIMA1_CM + MARGEN_HISTERESIS_CM) {
      // Hay lugar: abrir la válvula.
      digitalWrite(PIN_RELE_VALVULA, RELE_ENCENDIDO);
    }
    // En la franja intermedia el relé conserva su estado (histéresis).

    // --- Bomba sumergible ---
    if (distancia2 < DISTANCIA_MINIMA2_CM) {
      // El tanque elevado está lleno: no hay que bombear más.
      digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
    } else if (distancia1 > DISTANCIA_MAXIMA1_CM) {
      // La cisterna está casi vacía: apagar la bomba ANTES de que
      // quede fuera del agua (protección del hardware).
      digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
    } else if (distancia2 > DISTANCIA_MINIMA2_CM + MARGEN_HISTERESIS_CM
               && distancia1 < DISTANCIA_MAXIMA1_CM - MARGEN_HISTERESIS_CM) {
      // Hay lugar arriba Y agua suficiente abajo: bombear.
      digitalWrite(PIN_RELE_BOMBA, RELE_ENCENDIDO);
    }
    // En las franjas intermedias la bomba conserva su estado (histéresis).
  }

  // En este punto el procesador queda libre para realizar otras tareas.
}
dos-tanques-no-bloqueante.ino ESP32
// Sistema de dos tanques SIN delay(): la misma lógica del nivel intermedio
// (válvula de la cisterna + bomba al tanque elevado), pero no bloqueante.
// En vez de detener el programa entre mediciones, cada vuelta de loop()
// pregunta con millis() si ya pasaron 3 segundos desde la última medición;
// el resto del tiempo el procesador queda libre. Este programa es la base
// directa de tanques-iot.ino: sin código bloqueante, el ESP32 puede
// mantener el WiFi y publicar por MQTT sin desatender los tanques.
//
// SEGURIDAD: agua y electricidad no se llevan bien. Todas las conexiones
// eléctricas van fuera y POR ENCIMA del nivel del agua, los sensores no
// se deben mojar y la bomba sumergible solo se enciende estando bajo el
// agua. La práctica se realiza con válvula y bomba de baja tensión (12 V);
// la conexión a 220 V de la guía original queda reservada al docente o a
// una instalación supervisada, y nunca sobre la protoboard (desvío
// pedagógico acordado).
//
// Plataforma: ESP32 (DevKit v1).
// Conexión: sensor cisterna TRIG GPIO 5 / ECHO GPIO 18, relé de la
// válvula → GPIO 25; sensor elevado TRIG GPIO 19 / ECHO GPIO 21, relé de
// la bomba → GPIO 26.
// NOTA ELÉCTRICA (¡importante!): el ESP32 trabaja a 3,3 V. El HC-SR04
// clásico se alimenta con 5 V y su pin ECHO devuelve el eco a 5 V: eso
// dañaría el GPIO. Es OBLIGATORIO un divisor de tensión en CADA ECHO
// (ECHO —1 kΩ— GPIO —2 kΩ— GND), que baja los 5 V a ~3,3 V. TRIG no
// necesita nada. Alternativa sin divisores: el modelo HC-SR04P, que
// puede alimentarse directamente con 3,3 V.
//
// Basado en: Fragmentos de código 3 (patrón millis) y 4 (medición y
// control cada 3 segundos, sin la parte IoT) — Sistema de Tanques,
// Saberes Digitales (INET/EDUCAR 2019)
// Modernización: (1) constantes nombradas; millisAnterior como unsigned
// long (el long del original desborda la cuenta); portado a ESP32.
// (2) Histéresis de 5 cm en los umbrales para que el ruido del HC-SR04
// no haga traquetear los relés (el original conmutaba en el valor exacto).

// Sensor del tanque 1 (cisterna) y relé de la electroválvula.
const int PIN_TRIG1        = 5;
const int PIN_ECHO1        = 18; // con divisor 1 kΩ / 2 kΩ (ver nota)
const int PIN_RELE_VALVULA = 25;

// Sensor del tanque 2 (elevado) y relé de la bomba sumergible.
const int PIN_TRIG2      = 19;
const int PIN_ECHO2      = 21;   // con divisor 1 kΩ / 2 kΩ (ver nota)
const int PIN_RELE_BOMBA = 26;

// Módulos relé activo-alto, como en el original (HIGH = actuador andando).
// Si tu módulo es activo-bajo, invertí solo estas dos constantes.
const int RELE_ENCENDIDO = HIGH;
const int RELE_APAGADO   = LOW;

// Distancias del ejemplo original (se ajustan midiendo en TU maqueta).
const float DISTANCIA_MINIMA1_CM = 40.0;  // cisterna llena
const float DISTANCIA_MAXIMA1_CM = 140.0; // cisterna casi vacía (bomba al aire)
const float DISTANCIA_MINIMA2_CM = 40.0;  // tanque elevado lleno

// Colchón anti-traqueteo (modernización, ver encabezado).
const float MARGEN_HISTERESIS_CM = 5.0;

// El Fragmento de código 4 del original mide cada 3 segundos: el agua se
// mueve despacio y medir más seguido no aporta nada.
const unsigned long INTERVALO_MEDICION_MS = 3000;

// 58 microsegundos de eco por cada cm; timeout para ecos que no llegan.
const float MICROSEGUNDOS_POR_CM = 58.0;
const unsigned long TIMEOUT_ECO_US = 30000;

// Momento (ms desde el arranque) de la última medición.
unsigned long millisAnterior = 0;

// Mide la distancia en cm entre un sensor y la superficie del agua.
// Si no llega eco, pulseIn devuelve 0 y la distancia da 0: se interpreta
// como "tanque lleno" y todo se apaga. Falla segura.
// (pulseIn puede esperar el eco hasta 30 ms; junto con los microsegundos
// del disparo, es la única espera que queda en el programa, y es parte
// de la medición misma, no un delay() de relleno.)
float medirDistanciaCm(int pinTrig, int pinEcho) {
  digitalWrite(pinTrig, LOW);
  delayMicroseconds(2);
  digitalWrite(pinTrig, HIGH); // pulso de disparo de 10 microsegundos
  delayMicroseconds(10);
  digitalWrite(pinTrig, LOW);
  return pulseIn(pinEcho, HIGH, TIMEOUT_ECO_US) / MICROSEGUNDOS_POR_CM;
}

void setup() {
  pinMode(PIN_TRIG1, OUTPUT);
  pinMode(PIN_ECHO1, INPUT);
  pinMode(PIN_TRIG2, OUTPUT);
  pinMode(PIN_ECHO2, INPUT);
  pinMode(PIN_RELE_VALVULA, OUTPUT);
  pinMode(PIN_RELE_BOMBA, OUTPUT);

  // Estado inicial seguro: válvula cerrada y bomba apagada hasta que
  // las primeras mediciones digan otra cosa.
  digitalWrite(PIN_RELE_VALVULA, RELE_APAGADO);
  digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
}

void loop() {
  unsigned long millisActual = millis();

  // ¿Ya pasaron 3 segundos desde la última medición? Si no, no hacemos
  // nada: eso es NO bloquear.
  if (millisActual - millisAnterior >= INTERVALO_MEDICION_MS) {
    millisAnterior = millisActual;

    float distancia1 = medirDistanciaCm(PIN_TRIG1, PIN_ECHO1); // cisterna
    float distancia2 = medirDistanciaCm(PIN_TRIG2, PIN_ECHO2); // elevado

    // --- Válvula de la cisterna ---
    if (distancia1 < DISTANCIA_MINIMA1_CM) {
      // Cisterna llena: cerrar la válvula.
      digitalWrite(PIN_RELE_VALVULA, RELE_APAGADO);
    } else if (distancia1 > DISTANCIA_MINIMA1_CM + MARGEN_HISTERESIS_CM) {
      // Hay lugar: abrir la válvula.
      digitalWrite(PIN_RELE_VALVULA, RELE_ENCENDIDO);
    }
    // En la franja intermedia el relé conserva su estado (histéresis).

    // --- Bomba sumergible ---
    if (distancia2 < DISTANCIA_MINIMA2_CM) {
      // El tanque elevado está lleno: no hay que bombear más.
      digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
    } else if (distancia1 > DISTANCIA_MAXIMA1_CM) {
      // La cisterna está casi vacía: apagar la bomba ANTES de que
      // quede fuera del agua (protección del hardware).
      digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
    } else if (distancia2 > DISTANCIA_MINIMA2_CM + MARGEN_HISTERESIS_CM
               && distancia1 < DISTANCIA_MAXIMA1_CM - MARGEN_HISTERESIS_CM) {
      // Hay lugar arriba Y agua suficiente abajo: bombear.
      digitalWrite(PIN_RELE_BOMBA, RELE_ENCENDIDO);
    }
    // En las franjas intermedias la bomba conserva su estado (histéresis).
  }

  // En este punto el procesador queda libre para realizar otras tareas
  // (en tanques-iot.ino, acá se atiende la conexión WiFi/MQTT).
}
El mismo control de válvula y bomba del nivel intermedio, ahora sin delay(): el programa consulta millis() y solo mide cuando toca.

Teniendo un programa de funcionamiento de los tanques con un código no bloqueante (es decir, sin usar la función delay()) estamos en condiciones de incorporar IoT a nuestro proyecto.

Programar IoT

Con el panel listo y el código ya libre de bloqueos, falta el último eslabón: que el sistema publique los niveles y los estados en cada ronda de medición.

Esta parte práctica corresponde a la variante ESP32: el Arduino UNO clásico no tiene WiFi (el original lo resolvía con el módulo OBLOQ, hoy discontinuado). Cambiá la plataforma a ESP32 con el selector para ver el código y las instrucciones de conexión. Todo lo conceptual —qué se publica en cada feed, la convención de valores, el porqué del código no bloqueante— ya lo tenés resuelto con tu UNO en los pasos anteriores.

El programa final integra tres cosas que ya conocés: el control no bloqueante del paso 3, la conexión WiFi nativa del ESP32 y la publicación en Adafruit IO (mediante el protocolo MQTT, usando la Adafruit MQTT Library, que se instala desde el gestor de bibliotecas del Arduino IDE; reemplaza a la biblioteca ObloqAdafruit del original, discontinuada junto con su módulo).

Antes de subirlo, tenés que completar cuatro datos al comienzo del código: el nombre (SSID) y la contraseña de tu red WiFi, y el usuario y la clave que copiaste de Adafruit IO en el paso 2.

La publicación se engancha en el tiempo que el paso 3 le dejó libre al procesador: en cada vuelta de loop(), además de medir y decidir cuando toca, el programa mantiene viva la conexión con el servidor y publica los cambios: el estado (1 o 0) en valvula y bomba cada vez que un relé conmuta, y las distancias en tanque1 y tanque2 cuando difieren en 2 cm o más de lo último publicado. El original publicaba los cuatro feeds en cada medición; publicar solo los cambios muestra lo mismo en el panel sin agotar el cupo de la cuenta gratuita de Adafruit IO (el desvío está documentado en la ficha). Por esto es importante que nuestro código no sea bloqueante: si el programa quedara colgado en un delay(), la conexión no podría atenderse y las publicaciones se perderían. Bonus del mismo diseño: si internet se cae, los tanques siguen controlados, y al volver la conexión el panel se pone al día solo.

Ver el código completo · tanques-iot.ino

Nota: Este código está disponible para ESP32. Cambiá la plataforma con el selector del encabezado para verlo y practicarlo.

tanques-iot.ino ESP32
// Sistema de dos tanques con monitoreo IoT vía Adafruit IO (MQTT).
// La lógica no bloqueante de dos-tanques-no-bloqueante.ino sigue manejando
// válvula y bomba; además se publican cuatro feeds para verlos desde el
// Panel de Control de Adafruit (dos Gauge y dos Indicator, como en la guía):
//
//   "tanque1" y "tanque2": nivel medido, en cm de distancia entre el
//     sensor y el agua (número más chico = tanque más lleno, porque el
//     sensor mira desde arriba).
//   "valvula" y "bomba": 0 = apagada, 1 = encendida.
//
// SEGURIDAD: agua y electricidad no se llevan bien. Todas las conexiones
// eléctricas van fuera y POR ENCIMA del nivel del agua, los sensores no
// se deben mojar y la bomba sumergible solo se enciende estando bajo el
// agua. La práctica se realiza con válvula y bomba de baja tensión (12 V);
// la conexión a 220 V de la guía original queda reservada al docente o a
// una instalación supervisada, y nunca sobre la protoboard (desvío
// pedagógico acordado).
//
// Plataforma: ESP32 (DevKit v1) — usa su WiFi integrado.
// Conexión: sensor cisterna TRIG GPIO 5 / ECHO GPIO 18, relé de la
// válvula → GPIO 25; sensor elevado TRIG GPIO 19 / ECHO GPIO 21, relé de
// la bomba → GPIO 26.
// NOTA ELÉCTRICA (¡importante!): el ESP32 trabaja a 3,3 V. El HC-SR04
// clásico se alimenta con 5 V y su pin ECHO devuelve el eco a 5 V: eso
// dañaría el GPIO. Es OBLIGATORIO un divisor de tensión en CADA ECHO
// (ECHO —1 kΩ— GPIO —2 kΩ— GND). Alternativa: el modelo HC-SR04P, que
// puede alimentarse directamente con 3,3 V.
//
// Librería requerida (Library Manager del IDE de Arduino):
//   "Adafruit MQTT Library" (de Adafruit) — la misma elección que en el
//   proyecto del semáforo: cada feed es un objeto y publish() acepta
//   números directamente, igual que el olq.publish() del original.
//
// Basado en: Fragmento de código 4 — Sistema de Tanques,
// Saberes Digitales (INET/EDUCAR 2019)
// Modernización: (1) el módulo OBLOQ y la librería ObloqAdafruit quedaron
// discontinuados; se reemplazan por el WiFi nativo del ESP32 (WiFi.h) y
// MQTT contra io.adafruit.com. (2) El original publicaba los cuatro feeds
// en CADA medición (cada 3 s = 80 publicaciones por minuto, muy por encima
// del límite gratuito de Adafruit IO de 30/min); acá se publica solo
// cuando algo cambia: los estados al conmutar, y los niveles cuando la
// diferencia con lo último publicado es de 2 cm o más. (3) Histéresis de
// 5 cm en los umbrales de control (el original conmutaba en el valor
// exacto y el ruido del sensor haría traquetear los relés).

#include <WiFi.h>
#include "Adafruit_MQTT.h"
#include "Adafruit_MQTT_Client.h"

// ---------------------------------------------------------------------------
// Credenciales: reemplazar los cuatro valores por los propios.
// El usuario y la key de Adafruit se copian desde io.adafruit.com
// haciendo clic en el ícono de la "llave" (igual que en la guía original).
// ---------------------------------------------------------------------------
#define WIFI_SSID     "TU_SSID"
#define WIFI_PASSWORD "TU_PASSWORD"
#define IO_USERNAME   "TU_USUARIO_ADAFRUIT"
#define IO_KEY        "TU_KEY_ADAFRUIT"

// Servidor MQTT de Adafruit IO.
const char SERVIDOR_MQTT[] = "io.adafruit.com";
const int  PUERTO_MQTT     = 1883;

// Sensor del tanque 1 (cisterna) y relé de la electroválvula.
const int PIN_TRIG1        = 5;
const int PIN_ECHO1        = 18; // con divisor 1 kΩ / 2 kΩ (ver nota)
const int PIN_RELE_VALVULA = 25;

// Sensor del tanque 2 (elevado) y relé de la bomba sumergible.
const int PIN_TRIG2      = 19;
const int PIN_ECHO2      = 21;   // con divisor 1 kΩ / 2 kΩ (ver nota)
const int PIN_RELE_BOMBA = 26;

// Módulos relé activo-alto, como en el original (HIGH = actuador andando).
// Si tu módulo es activo-bajo, invertí solo estas dos constantes.
const int RELE_ENCENDIDO = HIGH;
const int RELE_APAGADO   = LOW;

// Distancias del ejemplo original (se ajustan midiendo en TU maqueta).
const float DISTANCIA_MINIMA1_CM = 40.0;  // cisterna llena
const float DISTANCIA_MAXIMA1_CM = 140.0; // cisterna casi vacía (bomba al aire)
const float DISTANCIA_MINIMA2_CM = 40.0;  // tanque elevado lleno

// Colchón anti-traqueteo de los relés (modernización, ver encabezado).
const float MARGEN_HISTERESIS_CM = 5.0;

// El Fragmento de código 4 del original mide cada 3 segundos.
const unsigned long INTERVALO_MEDICION_MS = 3000;

// Un nivel se re-publica solo si difiere en 2 cm o más de lo último
// publicado: los milímetros que bailan entre medición y medición no le
// aportan nada a la central y consumen el cupo gratuito de Adafruit IO.
const int UMBRAL_PUBLICACION_CM = 2;

// Si se cae la conexión MQTT, reintentamos cada tanto SIN bloquear:
// los tanques deben seguir controlados aunque no haya internet.
const unsigned long INTERVALO_REINTENTO_MQTT_MS = 5000;

// 58 microsegundos de eco por cada cm; timeout para ecos que no llegan.
const float MICROSEGUNDOS_POR_CM = 58.0;
const unsigned long TIMEOUT_ECO_US = 30000;

// Cliente MQTT sobre el WiFi del ESP32, y un objeto por feed.
WiFiClient clienteWifi;
Adafruit_MQTT_Client mqtt(&clienteWifi, SERVIDOR_MQTT, PUERTO_MQTT,
                          IO_USERNAME, IO_KEY);
Adafruit_MQTT_Publish feedTanque1(&mqtt, IO_USERNAME "/feeds/tanque1");
Adafruit_MQTT_Publish feedTanque2(&mqtt, IO_USERNAME "/feeds/tanque2");
Adafruit_MQTT_Publish feedValvula(&mqtt, IO_USERNAME "/feeds/valvula");
Adafruit_MQTT_Publish feedBomba(&mqtt, IO_USERNAME "/feeds/bomba");

// Momento (ms desde el arranque) de la última medición y del último
// intento de conexión MQTT.
unsigned long millisAnterior = 0;
unsigned long millisUltimoIntentoMqtt = 0;

// Todavía no se midió nada: no hay que publicar niveles inventados si
// la conexión MQTT llega antes que la primera medición.
bool hayMedicion = false;

// Estado actual del sistema (lo que muestran los relés y los sensores)...
int estadoValvula = 0; // 0 = cerrada, 1 = abierta
int estadoBomba   = 0; // 0 = apagada, 1 = encendida
int nivelCm1      = 0; // última medición de la cisterna, en cm
int nivelCm2      = 0; // última medición del tanque elevado, en cm

// ...y lo último que llegó a publicarse. Separarlos permite publicar solo
// los CAMBIOS y, si la conexión estaba caída, ponerse al día al volver.
int estadoValvulaPublicado = -1;    // -1 = todavía no se publicó nada
int estadoBombaPublicado   = -1;
int nivelPublicado1        = -1000; // imposible: fuerza la primera publicación
int nivelPublicado2        = -1000;

// Mide la distancia en cm entre un sensor y la superficie del agua.
// Si no llega eco, pulseIn devuelve 0 y la distancia da 0: se interpreta
// como "tanque lleno" y todo se apaga. Falla segura.
float medirDistanciaCm(int pinTrig, int pinEcho) {
  digitalWrite(pinTrig, LOW);
  delayMicroseconds(2);
  digitalWrite(pinTrig, HIGH); // pulso de disparo de 10 microsegundos
  delayMicroseconds(10);
  digitalWrite(pinTrig, LOW);
  return pulseIn(pinEcho, HIGH, TIMEOUT_ECO_US) / MICROSEGUNDOS_POR_CM;
}

// Diferencia sin signo entre dos enteros (para comparar contra el umbral).
int diferenciaAbsoluta(int a, int b) {
  if (a > b) {
    return a - b;
  }
  return b - a;
}

// Mantiene viva la conexión MQTT sin bloquear el control de los tanques:
// si está caída, reintenta como mucho una vez cada 5 segundos.
void mantenerConexionMqtt() {
  if (mqtt.connected()) {
    return;
  }

  unsigned long millisActual = millis();
  if (millisActual - millisUltimoIntentoMqtt < INTERVALO_REINTENTO_MQTT_MS) {
    return; // todavía no toca reintentar
  }
  millisUltimoIntentoMqtt = millisActual;

  Serial.print("Conectando a Adafruit IO... ");
  int8_t resultado = mqtt.connect(); // 0 = conexión exitosa
  if (resultado == 0) {
    Serial.println("conectado.");
  } else {
    Serial.println(mqtt.connectErrorString(resultado));
    mqtt.disconnect(); // deja el socket limpio para el próximo intento
  }
}

// Publica cada feed SOLO si cambió desde la última publicación exitosa
// (los niveles, solo si cambiaron 2 cm o más). Si publish() falla o no
// hay conexión, no se actualiza el registro: se reintenta en la próxima
// vuelta de loop().
void publicarCambios() {
  if (!mqtt.connected()) {
    return; // sin internet los tanques siguen; la central se pone al día al volver
  }
  if (!hayMedicion) {
    return; // solo se publica lo que realmente se midió
  }

  if (estadoValvula != estadoValvulaPublicado
      && feedValvula.publish((int32_t)estadoValvula)) {
    estadoValvulaPublicado = estadoValvula;
  }

  if (estadoBomba != estadoBombaPublicado
      && feedBomba.publish((int32_t)estadoBomba)) {
    estadoBombaPublicado = estadoBomba;
  }

  if (diferenciaAbsoluta(nivelCm1, nivelPublicado1) >= UMBRAL_PUBLICACION_CM
      && feedTanque1.publish((int32_t)nivelCm1)) {
    nivelPublicado1 = nivelCm1;
  }

  if (diferenciaAbsoluta(nivelCm2, nivelPublicado2) >= UMBRAL_PUBLICACION_CM
      && feedTanque2.publish((int32_t)nivelCm2)) {
    nivelPublicado2 = nivelCm2;
  }
}

void setup() {
  Serial.begin(115200);

  pinMode(PIN_TRIG1, OUTPUT);
  pinMode(PIN_ECHO1, INPUT);
  pinMode(PIN_TRIG2, OUTPUT);
  pinMode(PIN_ECHO2, INPUT);
  pinMode(PIN_RELE_VALVULA, OUTPUT);
  pinMode(PIN_RELE_BOMBA, OUTPUT);

  // Estado inicial seguro: válvula cerrada y bomba apagada hasta que
  // las primeras mediciones digan otra cosa.
  digitalWrite(PIN_RELE_VALVULA, RELE_APAGADO);
  digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);

  // Conexión WiFi. Esperar acá con delay() es aceptable porque el control
  // de los tanques todavía no arrancó; una vez dentro de loop(), nada
  // bloquea.
  Serial.print("Conectando a la red WiFi");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("WiFi conectado, IP: ");
  Serial.println(WiFi.localIP());
}

void loop() {
  unsigned long millisActual = millis();

  // --- Medición y control, cada 3 segundos (como el original) ---
  if (millisActual - millisAnterior >= INTERVALO_MEDICION_MS) {
    millisAnterior = millisActual;

    float distancia1 = medirDistanciaCm(PIN_TRIG1, PIN_ECHO1); // cisterna
    float distancia2 = medirDistanciaCm(PIN_TRIG2, PIN_ECHO2); // elevado

    // Los feeds publican el nivel en cm enteros, como el original.
    nivelCm1 = (int)distancia1;
    nivelCm2 = (int)distancia2;
    hayMedicion = true; // desde ahora, publicarCambios() tiene datos reales

    // Válvula de la cisterna.
    if (distancia1 < DISTANCIA_MINIMA1_CM) {
      // Cisterna llena: cerrar la válvula.
      digitalWrite(PIN_RELE_VALVULA, RELE_APAGADO);
      estadoValvula = 0;
    } else if (distancia1 > DISTANCIA_MINIMA1_CM + MARGEN_HISTERESIS_CM) {
      // Hay lugar: abrir la válvula.
      digitalWrite(PIN_RELE_VALVULA, RELE_ENCENDIDO);
      estadoValvula = 1;
    }
    // En la franja intermedia el relé conserva su estado (histéresis).

    // Bomba sumergible.
    if (distancia2 < DISTANCIA_MINIMA2_CM) {
      // El tanque elevado está lleno: no hay que bombear más.
      digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
      estadoBomba = 0;
    } else if (distancia1 > DISTANCIA_MAXIMA1_CM) {
      // La cisterna está casi vacía: apagar la bomba ANTES de que
      // quede fuera del agua (protección del hardware).
      digitalWrite(PIN_RELE_BOMBA, RELE_APAGADO);
      estadoBomba = 0;
    } else if (distancia2 > DISTANCIA_MINIMA2_CM + MARGEN_HISTERESIS_CM
               && distancia1 < DISTANCIA_MAXIMA1_CM - MARGEN_HISTERESIS_CM) {
      // Hay lugar arriba Y agua suficiente abajo: bombear.
      digitalWrite(PIN_RELE_BOMBA, RELE_ENCENDIDO);
      estadoBomba = 1;
    }
    // En las franjas intermedias la bomba conserva su estado (histéresis).
  }

  // --- Parte IoT ---
  // Gracias a que el control no bloquea, estas dos tareas se atienden en
  // CADA vuelta de loop(): esta es la razón de fondo por la que la guía
  // enseña a abandonar delay() antes de llegar a IoT.
  mantenerConexionMqtt();
  publicarCambios();
}
El sistema de tanques IoT completo: control no bloqueante + WiFi del ESP32 + publicación de niveles y estados en Adafruit IO.

Así queda funcionando el tablero que pedían los vecinos de Melina. Podés ver el sistema completo —los tanques trabajando más el panel reflejando cada publicación— en el simulador:

Simulación del comportamiento

La misma máquina de estados del firmware, portada a TypeScript, maneja el comportamiento del dispositivo. No se ejecuta el binario real: la ejecución del .hex con avr8js está en el roadmap.

Simulación lista. Presioná «Iniciar» para arrancar la secuencia.

Cierre del nivel

Al terminar este nivel, tu sistema de tanques dejó de ser solo una maqueta hidráulica: es un dispositivo IoT. El programa funciona con millis(), sin ningún delay() que lo cuelgue; sabés explicar por qué eso es imprescindible cuando hay que atender internet en simultáneo; y el panel de Adafruit IO muestra desde cualquier dispositivo cuánta agua hay en cada tanque y qué están haciendo la válvula y la bomba. Con esto queda completo el recorrido del proyecto. Si el grupo quiere seguir, en la ficha del proyecto está la extensión sugerida: convertir la lectura del sensor en litros disponibles y publicar ese dato, que es exactamente lo que un consorcio querría ver.

Evidencia de cierre

Al terminar este nivel, esto tiene que estar funcionando:

  • El sistema de tanques funciona con código no bloqueante basado en millis(): no queda ningún delay() que detenga el programa entre mediciones.
  • Podés explicar con tus palabras por qué el código bloqueante es un problema cuando el dispositivo tiene que atender internet en simultáneo.
  • El panel de Adafruit IO muestra los niveles de los dos tanques y el estado de la válvula y la bomba publicados por el dispositivo (variante ESP32).