← Índice del curso

Comunicación entre Dispositivos · Tema 33 · Taller

Taller: un sensor en la red con Ethernet

Es el momento de juntar las piezas. En los tres temas anteriores recorrimos la red de abajo hacia arriba: tramas Ethernet, direcciones IP, TCP y los protocolos de aplicación HTTP y MQTT. Acá construimos un nodo completo: un sensor de temperatura conectado por Ethernet con un módulo W5500 que obtiene su dirección por DHCP, publica sus lecturas por MQTT cada cierto tiempo, anuncia con mensaje retenido y última voluntad si está vivo, se reconecta solo cuando se cae el broker o el cable, ofrece una página web para ver el estado y cambiar el intervalo, y recuerda esa configuración en la EEPROM. Más que un sketch de ejemplo, es una guía de cómo estructurar un programa de red que no se cuelga.

01 · Objetivo

Qué vamos a construir

El proyecto es una estación de temperatura en red, con los requisitos de un dispositivo real:

  • Medir la temperatura con un DS18B20 (el sensor 1-Wire del tema 21), sin detener el programa mientras convierte.
  • Obtener su configuración de red por DHCP, con una dirección de respaldo si no hay servidor.
  • Publicar por MQTT la temperatura y un JSON con la lectura, a un intervalo configurable (30 s por omisión).
  • Declarar su estado: conectado retenido al arrancar y desconectado como última voluntad.
  • Recibir comandos: un mensaje MQTT con el intervalo nuevo.
  • Servir una página web con el estado, una ruta /api con la lectura en JSON y un formulario para cambiar el intervalo.
  • Guardar el intervalo en la EEPROM para que sobreviva a un corte de energía.
  • Recuperarse solo de la caída del cable, del broker o de la concesión DHCP, sin reiniciar.

Lo importante no son las líneas de código sino las decisiones de estructura: dividir el programa en servicios que no esperan, manejar la reconexión con retroceso exponencial, validar toda entrada que viene de la red y evitar desgastar la memoria. Son las mismas decisiones que aparecen en cualquier dispositivo conectado, con un W5500 o con un módulo celular.

02 · Materiales y cableado

Materiales y cableado

Conexiones del sensor en red Un microcontrolador se conecta por SPI (SCK, MOSI, MISO y CS) a un módulo Ethernet W5500, que a su vez se conecta con un cable RJ45 a un conmutador o router de la red local. Un sensor de temperatura DS18B20 se conecta a un pin digital del microcontrolador con una resistencia de pull-up de 4,7 kiloohmios hacia la alimentación. MicrocontroladorMega · ESP32 · STM32 Módulo W5500MAC + PHY + TCP/IP Conmutador / routerred local DS18B20sensor 1-Wire SCKMOSIMISOCSDQ cable RJ45 4,7 kΩ a 3,3 V (o 5 V) Alimentación: 3,3 V y masa común El W5500 puede consumir más de lo que entrega el regulador de 3,3 V de un Arduino Uno: usá un módulo con regulador propio o una fuente de 3,3 V aparte.
Materiales.
CantidadElementoComentario
1Placa con SPI y memoria suficiente (Arduino Mega 2560, ESP32, STM32…)Un Uno o un Nano pueden quedar justos de RAM con todo esto; compilá y mirá el informe de memoria
1Módulo Ethernet con chip W5500Los hay con regulador de 3,3 V y conector RJ45 con transformador integrado
1Sensor de temperatura DS18B20En cápsula TO-92 o sonda impermeable
1Resistencia de 4,7 kΩPull-up de la línea de datos 1-Wire (tema 21)
1Cable Ethernet y un conmutador o router de la redCon servidor DHCP (casi todos los routers lo traen)
1Una computadora con Mosquitto instaladoHace de broker en la red local y de herramienta de prueba
Conexiones (con el SPI de hardware del Arduino Mega; en otras placas, usá los pines SPI correspondientes).
SeñalPlacaMódulo / sensor
Reloj SPISCK (pin 52 en el Mega, 13 en el Uno)SCLK del W5500
Datos hacia el móduloMOSI (51 en el Mega, 11 en el Uno)MOSI
Datos desde el móduloMISO (50 en el Mega, 12 en el Uno)MISO
Selección10CS (SCS)
Reinicio (opcional)—RST (déjalo sin conectar o a 3,3 V según el módulo)
Datos 1-Wire2DQ del DS18B20, con 4,7 kΩ a la alimentación
Alimentación3,3 V (o 5 V según el módulo) y GNDVCC y GND del W5500 y del DS18B20

Tres cuidados de hardware. La alimentación del W5500: con el enlace activo consume bastante más que un microcontrolador pequeño, y el regulador de 3,3 V de algunas placas no lo entrega con holgura; ante reinicios o cuelgues inexplicables al conectar el cable, empezá por darle una fuente adecuada. Los niveles lógicos: el W5500 es de 3,3 V; si la placa es de 5 V verificá en la hoja del módulo qué señales aceptan 5 V. El chip select: si en la misma placa usás otros dispositivos SPI (una tarjeta SD, por ejemplo), cada uno necesita su CS y ninguno debe estar activo a la vez.

03 · Diseñar antes de programar

Diseñar antes de programar

Qué publica y qué escucha el nodo El nodo sensor01 publica la temperatura en planta/sensor01/temperatura y un JSON en planta/sensor01/lectura. Publica su estado, retenido, en planta/sensor01/estado, con la última voluntad desconectado. Escucha comandos en planta/sensor01/config/intervalo. Además atiende peticiones HTTP de un navegador en el puerto 80. Nodo sensor01MQTT + servidor web BrokerMosquitto Tablero / registrose suscribe a planta/# Navegadorhttp://IP-del-nodo/ Aplicaciónenvía comandos publicareenvíaHTTPintervalo planta/sensor01/temperatura · QoS 0planta/sensor01/lectura · JSON · QoS 0planta/sensor01/estado · retenido · última voluntadplanta/sensor01/config/intervalo · comando (suscripción) El nodo solo abre una conexión hacia el broker: no necesita que nadie pueda conectarse a él desde afuera (para MQTT).

Antes de escribir código se decide la interfaz externa del nodo, porque es lo más difícil de cambiar después (hay otros programas que dependen de ella). Para este nodo, con el identificador sensor01:

Temas MQTT del nodo.
TemaSentidoContenidoQoS / retenido
planta/sensor01/temperaturanodo → brokerTexto: 23.50 / no
planta/sensor01/lecturanodo → brokerJSON: {"t":23.5,"n":2880,"up":86400} (temperatura, lecturas, segundos desde el arranque)0 / no
planta/sensor01/estadonodo → brokerconectado / desconectado (última voluntad)retenido
planta/sensor01/config/intervalobroker → nodoSegundos, de 5 a 3600: 45suscripción
Rutas del servidor web.
RutaRespuestaQué hace
GET /200, HTMLPágina con la temperatura, los contadores y el formulario del intervalo
GET /api200, JSON{"id":"sensor01","t":23.5,"n":42,"fallas":0,"intervalo":30}
GET /set?i=45303 (o 400)Cambia el intervalo y redirige a la página; 400 si el valor es inválido
cualquier otra404No existe

Decisiones de diseño que conviene dejar por escrito:

  • Un tema por magnitud, más un JSON completo. El valor simple lo lee cualquier cosa (un tablero, un script); el JSON trae el contexto. Cuesta unos bytes más y no se paga por separado en la práctica (sección de tráfico).
  • La temperatura no es retenida: sería un dato viejo si el nodo se apagó. El estado sí: es el último hecho conocido.
  • Todo en décimas de grado enteras dentro del programa y formateado como texto al publicar: sin depender de cómo cada placa imprime decimales.
  • El intervalo tiene límites (5 a 3600 s) y se valida en el nodo, venga por MQTT o por la web. Nunca confiar en que el emisor sea correcto.
  • La configuración persiste en la EEPROM con un byte «mágico» que distingue una memoria con datos válidos de una vacía o corrupta.
  • Identificador único por nodo: sirve de nombre del cliente MQTT, de parte de los temas y (en el último byte) de la dirección MAC. Dos nodos con el mismo identificador se expulsan mutuamente del broker (tema 32).

04 · La estructura del programa

Un bucle que nunca espera

Estructura del bucle principal El bucle principal llama una y otra vez a cuatro servicios cortos que nunca esperan: el del sensor, el de MQTT, el del servidor web y el mantenimiento de Ethernet. Cada uno decide con millis si le toca trabajar. Ninguno bloquea, de modo que el servidor web y el cliente MQTT atienden sus eventos aunque el sensor esté midiendo. servicioSensor()lee el DS18B20 sin esperarservicioMqtt()conecta, publica, atiendeservicioWeb()atiende una peticiónEthernet.maintain()renueva la concesión DHCP loop(): se repite miles de veces por segundo Cada servicio guarda su propio estado (cuándo fue la última vez) y vuelve de inmediato si no le toca. Nada de delay() largos: un delay de 1 s en el loop sería un segundo sin atender la web ni el broker.

El error más común en un programa de red es usar delay() o esperas activas largas: «espero 800 ms a que el sensor convierta», «espero la respuesta del broker». Mientras el programa espera, no atiende el servidor web, no responde a los latidos de MQTT y no ve que el cable se desconectó. Con un solo hilo de ejecución, la solución es estructurar loop() como una lista de servicios cortos, cada uno con su propio estado, que consultan el reloj (millis()) y vuelven de inmediato si todavía no les toca trabajar:

  • servicioSensor() es una pequeña máquina de dos estados: «ocioso» (espera a que pase el intervalo) y «midiendo» (pidió la conversión al DS18B20 y espera 800 ms sin bloquear). Al terminar, lee, convierte a décimas y publica.
  • servicioMqtt() llama a mqtt.loop() si está conectado (atiende paquetes y envía el latido) o intenta reconectar según el calendario de la sección siguiente.
  • servicioWeb() mira si hay un cliente con datos, lee su petición con un tiempo límite, responde y cierra.
  • Ethernet.maintain() renueva la concesión DHCP cuando corresponde.

Cada vuelta de loop() toma unos pocos microsegundos cuando nada tiene que hacer. Dos servicios tienen un tope de tiempo explícito para que ninguno pueda monopolizar al resto: la lectura de la petición web (1 s) y el intento de conexión MQTT (lo limita el tiempo de espera de la conexión TCP de la biblioteca). El sketch evita además la clase String, que fragmenta la memoria dinámica en microcontroladores pequeños: usa arreglos de caracteres de tamaño fijo y snprintf.

05 · Mantenerse conectado

La conexión con el broker

Estados de la conexión con el broker El nodo pasa de sin enlace a esperando y a conectando. Si la conexión tiene éxito, queda conectado y publica y escucha; la espera vuelve a 5 segundos. Si falla, duplica la espera hasta 60 segundos y vuelve a esperar. Si se pierde la conexión, vuelve a esperar. Si se pierde el enlace físico, no intenta conectar. Sin enlacecable desconectado Esperandohasta el próximo intento Conectandomqtt.connect() Conectadopublica y atiende vuelve el enlacellegó la horaéxito: espera = 5 sse cae la conexiónfalla: espera × 2 (hasta 60 s) Esperas sucesivas tras fallar: 5, 10, 20, 40, 60, 60… segundos. Sin enlace físico no se intenta nada.

Una conexión TCP puede caerse en cualquier momento: el broker se reinicia, el router se apaga, el cable se afloja. Un dispositivo desatendido tiene que volver solo. La lógica es una pequeña máquina de estados:

  1. Si el enlace físico está caído (Ethernet.linkStatus() == LinkOFF), no se intenta nada; se espera a que vuelva.
  2. Si no está conectado, el servicio intenta mqtt.connect() cuando llega el momento programado.
  3. Si falla, la espera hasta el próximo intento se duplica (5, 10, 20, 40, 60 y luego 60 s). Si tiene éxito, vuelve a 5 s para la próxima caída.
  4. Al conectar, publica conectado retenido y se suscribe al tema de comandos (hay que repetirlo en cada conexión: con sesión limpia el broker olvida las suscripciones).

El retroceso exponencial es una protección para los dos lados. Si el broker se cae y vuelve, cien nodos que reintentaran cada segundo lo tumbarían con una avalancha de conexiones; con esperas crecientes (y, idealmente, un pequeño valor aleatorio) los reintentos se reparten. Y un nodo que reintenta a ciegas, sin límite de frecuencia, gasta energía y ancho de banda por nada. El costo es que la recuperación se retrasa: el nodo se entera de que el broker volvió a lo sumo un intervalo máximo después (la herramienta 1 del laboratorio lo calcula).

Mientras no hay conexión el sensor sigue midiendo, pero el nodo no acumula las lecturas: con QoS 0 y sin memoria de respaldo, lo que ocurre durante la caída se pierde. Para aplicaciones que no lo toleran, se guardaría un registro local (en una tarjeta SD o en la memoria) y se reenviaría al reconectar (sección de ampliaciones). Del lado del broker, el latido de 30 s permite detectar la caída del nodo en menos de 45 s y publicar la última voluntad.

06 · Un navegador como herramienta

El servidor web

El servidor web del nodo es mínimo a propósito. Atiende un cliente a la vez, lee la línea de petición (GET /set?i=45 HTTP/1.1), descarta los demás encabezados hasta la línea en blanco, compara la línea con tres rutas y responde. Algunas decisiones:

  • Se lee con tiempo límite y con buffers de tamaño fijo. Una línea más larga que el buffer se trunca; un cliente que no envía nada no cuelga al nodo más de un segundo.
  • Las respuestas llevan Connection: close y el servidor cierra la conexión al terminar: así cada petición libera su socket (el W5500 tiene solo ocho).
  • El cambio de intervalo se hace con GET para que sirva un formulario HTML sin JavaScript. Es un atajo didáctico: un GET no debería modificar nada (un navegador, un buscador o un antivirus pueden repetirlo), y la ruta no tiene autenticación. En un producto real sería un POST con credenciales.
  • El valor se valida dos veces: se convierte con atol (que lee los dígitos del comienzo y calla lo que sigue, por lo que 45x se acepta como 45) y se comprueba que esté entre 5 y 3600. Todo lo demás es un 400. La herramienta 2 del laboratorio reproduce este criterio.
  • La página se actualiza sola cada 10 segundos con una etiqueta meta refresh, sin JavaScript.

Lo que no hace, y habría que agregar para exponerlo fuera de una red de confianza: autenticación, HTTPS, límite de peticiones y protección contra solicitudes mal formadas. Un servidor web dentro de un microcontrolador es un lujo cómodo para configurar y diagnosticar en la red local, no una superficie para publicar en Internet.

07 · Poner a andar el nodo

Plan de pruebas

Se prueba por capas, de abajo hacia arriba, y no se avanza a la siguiente hasta que la anterior funciona:

  1. Sensor y monitor serie. Con el cable de red desconectado, el nodo debe medir y mostrar la temperatura; si aparece «sin lectura» o −127 °C, revisar el cableado y el pull-up del DS18B20.
  2. Enlace y DHCP. Conectar el cable: el monitor serie muestra «Nodo en 192.168.x.y». Sin servidor DHCP, tras 8 s usa la dirección de respaldo. Con el chip mal conectado: «No se encontró el W5500».
  3. Web. Abrir http://IP-del-nodo/ en el navegador y /api con curl. Probar /set?i=45 y /set?i=2 (debe dar 400) y una ruta inexistente (404).
  4. MQTT. Con Mosquitto activo, suscribirse con mosquitto_sub -h IP-DEL-BROKER -t "planta/#" -v -R y ver el estado, la temperatura y el JSON.
  5. Comandos. Publicar mosquitto_pub -h IP-DEL-BROKER -t planta/sensor01/config/intervalo -m 10 y comprobar que las lecturas llegan cada 10 s. Probar valores inválidos (2, abc, 3601): no deben cambiar nada.
  6. Persistencia. Cambiar el intervalo, quitar la alimentación, volver a conectarla: debe arrancar con el valor guardado.
  7. Fallas inducidas. Detener y reiniciar Mosquitto: el nodo debe reconectar solo (se ve el calendario 5, 10, 20… en el monitor). Desconectar y volver a conectar el cable. Desconectar la alimentación del nodo y observar en el suscriptor la última voluntad desconectado (tarda hasta 45 s).
  8. Estabilidad. Dejarlo funcionando horas: el contador de lecturas sube, no hay reinicios, la memoria libre no baja.

08 · Con el microcontrolador

El código completo

Es un único archivo, organizado por responsabilidades. Está pensado para placas AVR (como el Mega), y los comentarios señalan lo que cambia en ESP32 y otras. Bibliotecas: Ethernet, PubSubClient, OneWire y DallasTemperature, instaladas desde el gestor de bibliotecas.

#include <SPI.h>
#include <Ethernet.h>
#include <PubSubClient.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <EEPROM.h>

// ---------- Configuración del nodo ----------
const char ID_NODO[] = "sensor01";                              // único en el broker
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0x01 };              // el último byte, distinto en cada nodo
const int PIN_CS = 10;
const int PIN_DS18B20 = 2;
IPAddress ipRespaldo(192, 168, 1, 50);                          // si no hay DHCP (ajustar a tu red)
IPAddress broker(192, 168, 1, 10);                              // el equipo con Mosquitto (ajustar)
const uint16_t PUERTO_BROKER = 1883;
const long INTERVALO_MIN = 5, INTERVALO_MAX = 3600;
const uint16_t INTERVALO_INICIAL = 30;
const uint8_t MAGIA = 0xA5;

// ---------- Objetos ----------
EthernetServer web(80);
EthernetClient clienteMqtt;
PubSubClient mqtt(clienteMqtt);
OneWire bus(PIN_DS18B20);
DallasTemperature sensores(&bus);

// ---------- Estado ----------
struct Config { uint8_t magia; uint16_t intervalo; } config;
int decimas = 0;                                                // temperatura en décimas de grado
bool hayLectura = false;
unsigned long lecturas = 0, fallas = 0;
char temaTemp[48], temaJson[48], temaEstado[48], temaIntervalo[48];

// ---------- Configuración persistente ----------
void cargarConfig() {                                           // en ESP32: EEPROM.begin(16) antes, y commit() al escribir
    EEPROM.get(0, config);
    if (config.magia != MAGIA || config.intervalo < INTERVALO_MIN || config.intervalo > INTERVALO_MAX) {
        config.magia = MAGIA;
        config.intervalo = INTERVALO_INICIAL;
        EEPROM.put(0, config);
    }
}

bool cambiarIntervalo(long valor) {                             // el único lugar donde se acepta un valor nuevo
    if (valor < INTERVALO_MIN || valor > INTERVALO_MAX) return false;
    if (config.intervalo != valor) {                            // se escribe solo si cambió: la EEPROM se desgasta
        config.intervalo = (uint16_t)valor;
        EEPROM.put(0, config);
    }
    return true;
}

// ---------- Utilidades ----------
void formatearTemp(char *salida, size_t n, int d) {             // décimas -> "23.5" o "-0.5" (el signo va aparte)
    int a = d < 0 ? -d : d;
    snprintf(salida, n, "%s%d.%d", d < 0 ? "-" : "", a / 10, a % 10);
}

// ---------- Sensor (sin esperas) ----------
void publicarLectura() {
    if (!mqtt.connected()) return;
    char t[10], json[64];
    formatearTemp(t, sizeof(t), decimas);
    snprintf(json, sizeof(json), "{\"t\":%s,\"n\":%lu,\"up\":%lu}", t, lecturas, millis() / 1000);
    mqtt.publish(temaTemp, t);
    mqtt.publish(temaJson, json);
}

void servicioSensor() {
    static unsigned long inicio = 0, ultimo = 0;
    static bool midiendo = false;
    unsigned long ahora = millis();
    if (!midiendo) {
        bool primera = (lecturas + fallas) == 0;
        if (primera || ahora - ultimo >= (unsigned long)config.intervalo * 1000UL) {
            sensores.requestTemperatures();                     // con setWaitForConversion(false) vuelve enseguida
            inicio = ahora;
            midiendo = true;
        }
    } else if (ahora - inicio >= 800) {                         // a 12 bits la conversión tarda hasta 750 ms
        float t = sensores.getTempCByIndex(0);
        midiendo = false;
        ultimo = inicio;
        if (t == DEVICE_DISCONNECTED_C) { hayLectura = false; fallas++; }
        else { decimas = (int)lroundf(t * 10); hayLectura = true; lecturas++; publicarLectura(); }
    }
}

// ---------- MQTT ----------
void alRecibir(char *tema, byte *datos, unsigned int largo) {   // se invoca desde mqtt.loop()
    if (strcmp(tema, temaIntervalo) != 0 || largo == 0 || largo > 4) return;
    long valor = 0;
    for (unsigned int i = 0; i < largo; i++) {
        if (datos[i] < '0' || datos[i] > '9') return;           // solo dígitos: lo que llega por la red no es confiable
        valor = valor * 10 + (datos[i] - '0');
    }
    if (cambiarIntervalo(valor)) Serial.println(F("Intervalo cambiado por MQTT"));
}

void servicioMqtt() {
    static unsigned long proximo = 0, espera = 5000;
    if (mqtt.connected()) { mqtt.loop(); return; }
    if (Ethernet.linkStatus() == LinkOFF) return;               // sin cable no se intenta nada
    unsigned long ahora = millis();
    if ((long)(ahora - proximo) < 0) return;                    // todavía no es la hora del próximo intento
    if (mqtt.connect(ID_NODO, nullptr, nullptr, temaEstado, 1, true, "desconectado")) {
        mqtt.publish(temaEstado, "conectado", true);            // retenido
        mqtt.subscribe(temaIntervalo);                          // hay que repetirlo en cada conexión
        espera = 5000;
        Serial.println(F("MQTT conectado"));
    } else {
        Serial.print(F("MQTT falló, estado ")); Serial.println(mqtt.state());
    }
    proximo = ahora + espera;
    if (!mqtt.connected()) espera = min(espera * 2, 60000UL);   // 5, 10, 20, 40, 60, 60... s
}

// ---------- Servidor web ----------
void cabecera(EthernetClient &c, const __FlashStringHelper *estado, const __FlashStringHelper *tipo) {
    c.print(F("HTTP/1.1 ")); c.println(estado);
    if (tipo) { c.print(F("Content-Type: ")); c.println(tipo); }
    c.println(F("Connection: close"));
    c.println();
}

void paginaPrincipal(EthernetClient &c) {
    char t[10];
    formatearTemp(t, sizeof(t), decimas);
    cabecera(c, F("200 OK"), F("text/html; charset=utf-8"));
    c.print(F("<!DOCTYPE html><html><head><meta charset=utf-8><meta http-equiv=refresh content=10><title>"));
    c.print(ID_NODO);
    c.print(F("</title></head><body><h1>"));
    c.print(ID_NODO);
    c.print(F("</h1><p>Temperatura: "));
    if (hayLectura) { c.print(t); c.print(F(" &deg;C")); } else c.print(F("sin lectura"));
    c.print(F("</p><p>Lecturas: ")); c.print(lecturas);
    c.print(F(" &middot; fallas: ")); c.print(fallas);
    c.print(F("</p><form action=/set><label>Intervalo (s) <input name=i value=")); c.print(config.intervalo);
    c.print(F("></label> <button>Guardar</button></form></body></html>"));
}

void respuestaJson(EthernetClient &c) {
    char t[10];
    formatearTemp(t, sizeof(t), decimas);
    cabecera(c, F("200 OK"), F("application/json"));
    c.print(F("{\"id\":\"")); c.print(ID_NODO);
    c.print(F("\",\"t\":"));
    if (hayLectura) c.print(t); else c.print(F("null"));
    c.print(F(",\"n\":")); c.print(lecturas);
    c.print(F(",\"fallas\":")); c.print(fallas);
    c.print(F(",\"intervalo\":")); c.print(config.intervalo);
    c.println('}');
}

void servicioWeb() {
    EthernetClient c = web.available();                         // un cliente con datos, si lo hay
    if (!c) return;
    char linea[64], peticion[64] = "";
    size_t n = 0;
    bool primera = true;
    unsigned long t0 = millis();
    while (c.connected() && millis() - t0 < 1000) {           // tiempo límite: un cliente mudo no nos cuelga
        if (!c.available()) continue;
        char ch = c.read();
        if (ch != '\n') { if (n < sizeof(linea) - 1) linea[n++] = ch; continue; }
        linea[n] = 0;
        if (primera) { strcpy(peticion, linea); primera = false; }
        else if (n <= 1) break;                                 // línea vacía (solo '\r'): terminaron los encabezados
        n = 0;
    }
    if (strncmp(peticion, "GET / ", 6) == 0) paginaPrincipal(c);
    else if (strncmp(peticion, "GET /api ", 9) == 0) respuestaJson(c);
    else if (strncmp(peticion, "GET /set?i=", 11) == 0) {
        if (cambiarIntervalo(atol(peticion + 11))) {
            c.println(F("HTTP/1.1 303 See Other")); c.println(F("Location: /"));
            c.println(F("Connection: close")); c.println();
        } else cabecera(c, F("400 Bad Request"), nullptr);
    } else cabecera(c, F("404 Not Found"), nullptr);
    delay(1);
    c.stop();                                                   // libera el socket del W5500
}

// ---------- Programa principal ----------
void setup() {
    Serial.begin(115200);
    cargarConfig();
    sensores.begin();
    sensores.setWaitForConversion(false);                       // la conversión no bloquea el programa
    snprintf(temaTemp,      sizeof(temaTemp),      "planta/%s/temperatura",      ID_NODO);
    snprintf(temaJson,      sizeof(temaJson),      "planta/%s/lectura",          ID_NODO);
    snprintf(temaEstado,    sizeof(temaEstado),    "planta/%s/estado",           ID_NODO);
    snprintf(temaIntervalo, sizeof(temaIntervalo), "planta/%s/config/intervalo", ID_NODO);

    Ethernet.init(PIN_CS);
    if (Ethernet.begin(mac, 8000) == 0) {                       // DHCP con 8 s de espera
        if (Ethernet.hardwareStatus() == EthernetNoHardware) { Serial.println(F("No se encontró el W5500")); while (true) delay(1000); }
        Serial.println(F("Sin DHCP: se usa la IP de respaldo"));
        Ethernet.begin(mac, ipRespaldo);
    }
    web.begin();
    mqtt.setServer(broker, PUERTO_BROKER);
    mqtt.setKeepAlive(30);                                      // el broker nos da por caídos a los 45 s sin noticias
    mqtt.setCallback(alRecibir);
    Serial.print(F("Nodo en ")); Serial.println(Ethernet.localIP());
}

void loop() {
    servicioSensor();
    servicioMqtt();
    servicioWeb();
    Ethernet.maintain();                                        // renueva la concesión DHCP
}

Cuatro detalles que merecen atención. El orden de las comprobaciones en servicioMqtt: primero el latido si está conectado, después el enlace, después el calendario; invertirlo haría reintentar sin cable. La comparación (long)(ahora - proximo) < 0 es la forma segura de comparar tiempos con millis(), que se desborda cada unos 49 días y hace fallar a una comparación ingenua. Las cadenas de la página web están en memoria de programa (F()) para no ocupar la escasa RAM de un AVR. Y cada publicación espera su turno: publicarLectura solo corre si hay conexión; si no, la lectura se descarta.

09 · Ver lo que pasa

Verificar con herramientas

Un dispositivo de red se verifica desde afuera, con las mismas herramientas que se usarían para cualquier servicio. Con el nodo en 192.168.1.50 y Mosquitto en 192.168.1.10:

Comandos de verificación.
Qué se pruebaComandoResultado esperado
Que el nodo responde en la redping 192.168.1.50Respuestas del nodo (el W5500 responde ICMP)
La página principalcurl -i http://192.168.1.50/HTTP/1.1 200 OK y el HTML con la temperatura
La APIcurl http://192.168.1.50/api{"id":"sensor01","t":23.5,"n":12,"fallas":0,"intervalo":30}
Cambio válidocurl -i "http://192.168.1.50/set?i=45"303 See Other con Location: /
Cambio inválidocurl -i "http://192.168.1.50/set?i=2"400 Bad Request; el intervalo no cambia
Todo el tráfico MQTT del nodomosquitto_sub -h 192.168.1.10 -t "planta/#" -vEstado retenido, temperatura y JSON cada intervalo
Un comandomosquitto_pub -h 192.168.1.10 -t planta/sensor01/config/intervalo -m 10Las lecturas pasan a llegar cada 10 s
La última voluntadCortar la alimentación del nodo y mirar el suscriptorHasta 45 s después: planta/sensor01/estado desconectado

Para ver el diálogo real en el cable, un analizador de paquetes como Wireshark (con el filtro ip.addr == 192.168.1.50) muestra todo lo que estudiamos: la solicitud ARP y la respuesta, los cuatro mensajes de DHCP, el saludo de tres vías hacia el puerto 1883, el CONNECT y el CONNACK, los PUBLISH con su identificación de tema, los PINGREQ cada 30 s y los segmentos TCP con sus ACK. Wireshark dibuja los campos de cada capa, con los números que calculamos a mano en los temas 29 a 32. Para capturar el tráfico de otro equipo hace falta un puerto espejado del conmutador o ejecutar el analizador en la computadora donde corre el broker.

Un truco útil para el diagnóstico: suscribirse a $SYS/# en Mosquitto muestra estadísticas del propio broker (clientes conectados, mensajes recibidos); y mosquitto -v en la terminal registra cada conexión, desconexión y suscripción, de modo que se ve con claridad cuándo el nodo conecta, con qué identificador y si el broker lo desconecta.

10 · Analogía con programación

El nodo en Python

Las partes con lógica propia del nodo (formatear la temperatura sin perder el signo, validar un comando, decidir qué ruta web ejecutar, calcular los intentos de reconexión y el tráfico diario) son funciones puras que se pueden probar sin hardware. El modelo siguiente las reproduce con las mismas reglas que el sketch.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

import math
import re

ID_NODO = "sensor01"


def a_decimas(temperatura):
    return int(math.copysign(math.floor(abs(temperatura) * 10 + 0.5), temperatura))     # como lroundf: el medio se aleja del cero


def formatear(decimas):
    signo = "-" if decimas < 0 else ""
    d = abs(decimas)
    return f"{signo}{d // 10}.{d % 10}"                    # el signo aparte: -5 décimas es "-0.5", no "0.5"


def json_lectura(decimas, lecturas, segundos):
    return f'{{"t":{formatear(decimas)},"n":{lecturas},"up":{segundos}}}'


def validar_intervalo(texto):
    if not (1 <= len(texto) <= 4 and texto.isdigit()):
        return None
    valor = int(texto)
    return valor if 5 <= valor <= 3600 else None


def enrutar(linea):
    if linea.startswith("GET / "):
        return 200, "página de estado"
    if linea.startswith("GET /api "):
        return 200, "JSON con la lectura"
    if linea.startswith("GET /set?i="):
        m = re.match(r"\s*([+-]?\d+)", linea[11:])            # como atol: lee el número del comienzo y calla el resto
        valor = int(m.group(1)) if m else 0
        return (303, f"intervalo = {valor} s") if 5 <= valor <= 3600 else (400, "intervalo inválido")
    return 404, "ruta desconocida"


def reconexion(caida, primera=5, maximo=60):
    intentos, t, espera = [], 0, primera
    while True:
        intentos.append(t)
        if t >= caida:
            return intentos                                  # este intento encuentra al broker de vuelta
        t += espera
        espera = min(espera * 2, maximo)


def bytes_publish(tema, datos):
    return 2 + 2 + len(tema) + len(datos)


def trafico_diario(intervalo, keepalive=30):
    t1 = f"planta/{ID_NODO}/temperatura"
    t2 = f"planta/{ID_NODO}/lectura"
    ciclos = 86400 // intervalo
    app = bytes_publish(t1, "23.5") + bytes_publish(t2, '{"t":23.5,"n":2880,"up":86400}')
    return ciclos, ciclos * app + (86400 // keepalive) * 4


for t in (23.46, -0.5, 0.04, -12.34, 125.0):
    d = a_decimas(t)
    print(f"{t:>7} °C -> {d:>5} décimas -> {formatear(d)}")
print("JSON:", json_lectura(235, 2880, 86400), "->", len(json_lectura(235, 2880, 86400)), "caracteres")

for texto in ("45", "4", "3600", "3601", "abc", "30 ", "0030"):
    print(f"comando {texto!r}: {validar_intervalo(texto)}")

for linea in ("GET / HTTP/1.1", "GET /api HTTP/1.1", "GET /set?i=45 HTTP/1.1", "GET /set?i=2 HTTP/1.1", "GET /set?i=45x HTTP/1.1", "GET /otra HTTP/1.1", "POST /set HTTP/1.1"):
    print(f"{linea!r}: {enrutar(linea)}")

for caida in (0, 12, 100, 400):
    print(f"broker caído {caida} s -> intentos en", reconexion(caida))

ciclos, total = trafico_diario(30)
print(f"Cada 30 s: {ciclos} ciclos al día, {total} bytes de aplicación (unos {total / 1024:.0f} KiB)")

Resultado: 23,46 °C se convierte en 235 décimas y sale como 23.5; −0,5 °C son −5 décimas y sale -0.5 (con el signo aparte); −12,34 °C da −123 décimas y -12.3; 0,04 °C da 0.0. El JSON de la lectura mide 30 caracteres. Los comandos 45, 3600 y 0030 se aceptan (45, 3600 y 30), y 4, 3601, abc y 30 se rechazan. Las rutas dan 200 para GET / y GET /api, 303 para GET /set?i=45 (y también para 45x), 400 para i=2 y 404 para GET /otra y para POST /set. Con el broker caído 12 s, los intentos ocurren en 0, 5 y 15 s; con 100 s, en 0, 5, 15, 35, 75 y 135 s; con 400 s, once intentos. Y publicando cada 30 s hay 2880 ciclos al día con 276 480 bytes de aplicación (unos 270 KiB).

Probar así la lógica antes de llevarla al microcontrolador es una buena costumbre: el error del signo de −0,5 °C, o el de aceptar 45x como 45, se descubren en segundos en la computadora y en horas en el banco de pruebas.

11 · Exploración interactiva

Laboratorio: reconexión, rutas, tráfico, formato y comandos

El laboratorio tiene cinco herramientas. La primera calcula el calendario de reconexión tras la caída del broker. La segunda simula las rutas del servidor web. La tercera estima el tráfico diario del nodo y de una flota. La cuarta muestra cómo se formatea la temperatura y la trampa del signo. La quinta valida los comandos de intervalo y estima el desgaste de la EEPROM.

1. Reconexión con retroceso

Instantes de los intentos (desde la caída)—
Cantidad de intentos—
Cuánto tarda en volver tras el regreso del broker—
Lecturas que no se publican—
Veredicto—

Elegí cuánto dura la caída del broker.

2. Rutas del servidor web

Ruta reconocida—
Código de estado—
Efecto—
Encabezado destacado de la respuesta—

Escribí una línea de petición.

3. Tráfico diario

Ciclos al día—
Bytes de aplicación (MQTT)—
Bytes en el cable por nodo—
Total de la flota—
Veredicto—

Elegí el intervalo y la cantidad de nodos.

4. Formato de la temperatura

En décimas de grado—
Formato ingenuo (cociente y resto)—
Formato correcto (signo aparte)—
JSON publicado—
Tamaño—

Escribí una temperatura.

5. Comando de intervalo

Resultado—
Intervalo resultante—
Motivo—
Desgaste de la EEPROM—

Escribí el mensaje que llegó por MQTT.

En la herramienta 1, el primer intento ocurre en el instante 0 (apenas se detecta la caída) y la espera se duplica tras cada fallo hasta el máximo. En la 2, se reproduce el criterio del sketch: atol lee el número del comienzo y el rango es de 5 a 3600. En la 3, cada publicación viaja en su propia trama y el broker contesta con un ACK de 84 bytes en el cable; los latidos son PINGREQ y PINGRESP de 2 bytes. En la 4, el redondeo es el habitual (el medio se aleja del cero). En la 5, se supone un valor distinto en cada cambio, con la escritura solo si cambia.

Recorrido guiado

  1. Con los valores iniciales (broker caído 100 s, esperas de 5 a 60 s, lecturas cada 30 s): los intentos ocurren en 0, 5, 15, 35, 75 y 135 s; son 6 intentos, el último conecta a los 135 s, o sea 35 s después de que el broker volvió, y quedan sin publicar 5 lecturas. Con una caída de 12 s: intentos en 0, 5 y 15; vuelve 3 s tarde. Con 400 s: once intentos, y a partir de los 75 s las esperas son de 60 s.
  2. Subí la espera inicial a 1 s: los primeros reintentos son rápidos (0, 1, 3, 7, 15, 31…), pero el nodo gasta más intentos si la caída es larga. Bajá la espera máxima a 10 s: se recupera antes, a costa de más intentos (es el compromiso entre recuperación y carga).
  3. En la herramienta 2, con GET /set?i=45 HTTP/1.1: 303 See Other, guarda 45 s y redirige a la página. Probá GET / HTTP/1.1 (200, HTML), GET /api HTTP/1.1 (200, JSON), GET /set?i=2 HTTP/1.1 (400: fuera de rango), GET /set?i=abc HTTP/1.1 (400: no hay número) y GET /otra HTTP/1.1 (404).
  4. Probá GET /set?i=45x HTTP/1.1: se acepta como 45 porque atol ignora lo que sigue. Es un defecto menor, pero muestra por qué conviene validar el texto completo, como hace el comando MQTT.
  5. En la herramienta 3, con los valores iniciales (cada 30 s, sensor01, latido de 30 s, un nodo): 2880 ciclos al día, unos 270 KiB de MQTT y cerca de 1,6 MiB en el cable, de modo que más del 90 % del tráfico son encabezados y confirmaciones. Con 1000 nodos: unos 1,6 GiB al día, unos 150 kbit/s promedio. Subí el intervalo a 300 s: baja a 0,58 MiB, menos de un tercio, y ahora casi todo son latidos (el 80 %): con intervalos largos, el latido pesa más que los datos.
  6. En la herramienta 4, con 23.46: 235 décimas, 23.5. Con −0.5: −5 décimas; el formato ingenuo da «0.5» (pierde el signo) y el correcto «−0.5». Probá −12.34 (−12.3), 0.04 (0.0) y −127 (sensor desconectado).
  7. En la herramienta 5, con 45: aceptado. Con 4 (fuera de rango), 3601, abc y 30 (con un espacio): rechazados. Con 0030: aceptado como 30. Con 4 cambios por día, la EEPROM dura unos 68 años; si un programa defectuoso escribiera 1000 veces por día, menos de un año (por eso se escribe solo si cambia, y nunca en un bucle).
Desafío: la flota de la fábrica

Una planta tiene 500 nodos como este, cada uno con un intervalo de 10 s y un latido de 30 s. ¿Cuánto tráfico genera, y qué cambiarías si el enlace fuera celular, de pago por MB? Con la herramienta 3: cada nodo mueve unos 3,9 MiB por día en el cable (8640 ciclos), unos 1,9 GiB para los 500: alrededor de 0,19 Mbit/s promedio. Sobre Ethernet es despreciable. Sobre un enlace celular, cambiaría: lo publicado en dos temas (la temperatura sola está en el JSON), un intervalo más largo, lecturas agrupadas en un solo mensaje cada minuto, un latido más largo y un formato más compacto. Son las mismas ideas del tema 32 sobre el costo de los encabezados.

12 · Qué viene después

Ampliaciones y buenas prácticas

  • Perro guardián (watchdog). Un temporizador de hardware que reinicia la placa si el programa deja de «alimentarlo». Es la última defensa contra un cuelgue en la pila de red, y es obligatorio en equipos desatendidos.
  • Seguridad. Usuario y contraseña en MQTT (y ACL en el broker), TLS con un módulo y un microcontrolador que lo soporten (ESP32), autenticación en la web y desactivar la ruta de configuración fuera de la red de confianza.
  • Hora real. Agregar un cliente NTP (UDP puerto 123) y sellos de tiempo en el JSON; sin hora, el broker y la base de datos solo conocen el momento de llegada.
  • Respaldo local. Guardar las lecturas en una tarjeta SD o en una memoria durante las caídas y enviarlas al reconectar, con QoS 1 y cuidando el orden.
  • Más sensores. Varios DS18B20 en el mismo bus 1-Wire (cada uno con su dirección de 64 bits, tema 21), o un BME280 por I²C (temas 16 a 20) con humedad y presión.
  • Visualización. Un suscriptor que guarde los datos en una base de series de tiempo y los muestre en un tablero; herramientas como Node-RED, InfluxDB y Grafana se conectan directamente a MQTT.
  • Integración doméstica. Plataformas como Home Assistant descubren dispositivos MQTT con un mensaje de configuración retenido, sin escribir nada en el tablero.
  • Actualización remota. La carga de firmware por la red (OTA) evita ir a cada placa con el cable USB, pero es un camino de ataque si no se firma ni autentica.
  • Ahorro de energía. En un nodo a baterías, Ethernet es un mal punto de partida (el W5500 consume del orden de cien miliamperios); el patrón es medir, conectar, publicar y dormir (ESP32 con Wi-Fi), o usar un enlace de bajo consumo (LoRa, Zigbee).

Con este taller termina la parte de redes IP. Las tecnologías que siguen en el curso —Wi-Fi, Bluetooth y los enlaces de largo alcance de baja potencia— cambian la capa física y de enlace, pero conservan lo que acabamos de ver: direcciones, tramas, TCP o UDP y MQTT o HTTP encima.

13 · Comprensión

Confusiones frecuentes

«Un delay() corto en el loop no hace daño»

Cada milisegundo en un delay es un milisegundo sin atender la red. Una espera de 800 ms para el sensor hace que el servidor web y el latido MQTT se retrasen, y el broker puede dar por caído al nodo. Se usa millis() y estados.

«Si la conexión MQTT se cae, la biblioteca la recupera sola»

PubSubClient no reconecta por su cuenta: hay que verificar mqtt.connected() y volver a llamar a connect(), a subscribe() y a publicar el estado.

«Reintentar rápido y siempre es lo más robusto»

Reintentar sin pausa agobia al broker (y a la red) cuando vuelve, y gasta energía. El retroceso exponencial reparte los intentos y se estabiliza en un máximo razonable.

«La última voluntad se publica cuando el nodo se apaga»

Se publica cuando el broker detecta una desconexión anormal, y la detecta por el latido, no por el apagado: puede tardar hasta 1,5 veces el intervalo. Si el nodo se desconecta ordenadamente con DISCONNECT, no se publica.

«Validar el intervalo en la página web alcanza»

Hay dos entradas (web y MQTT) y las dos tienen que pasar por la misma validación, en el nodo. Un cliente puede saltear el formulario y mandar cualquier cosa.

«Guardar en la EEPROM en cada lectura asegura no perder nada»

Agota la memoria en semanas: cada celda aguanta del orden de 100 000 escrituras. Se guarda solo lo que cambia (la configuración), no los datos.

«Con la IP fija de respaldo no necesito DHCP»

La IP de respaldo puede chocar con otro equipo y deja al nodo sin la puerta de enlace y el DNS correctos. Es un seguro para no quedar mudo, no el modo normal.

«Si ve el estado en el broker, el nodo funciona»

El estado retenido muestra el último mensaje, no el actual. Un nodo desconectado de forma anormal solo pasa a «desconectado» cuando vence el latido. Hay que mirar también que sigan llegando las lecturas.

14 · Práctica guiada

Predecí antes de comprobar

1. Calendario de reconexión

El broker se cae y vuelve a los 50 s. Con espera inicial 5 s y máxima 60 s, ¿en qué instantes intenta el nodo y cuándo conecta?

Ver solución

Los intentos ocurren en 0, 5, 15, 35 y 75 s (esperas de 5, 10, 20 y 40 s). El broker vuelve a los 50 s, de modo que el intento de los 35 s todavía falla y el de los 75 s conecta: 25 s después del regreso.

2. Lecturas perdidas

Con lecturas cada 20 s y el calendario anterior, ¿cuántas lecturas no se publican?

Ver solución

El nodo estuvo desconectado de 0 a 75 s. Las lecturas ocurren en 0, 20, 40 y 60 s: 4 lecturas sin publicar (la de los 80 s ya se publica).

3. Ruta web

¿Qué responde el nodo a GET /set?i=3601 HTTP/1.1, a GET /set?i=3600 HTTP/1.1 y a GET /Api HTTP/1.1?

Ver solución

3601 está fuera del rango: 400 Bad Request. 3600 es el máximo permitido: 303 y guarda 3600 s. /Api no coincide con /api (la comparación distingue mayúsculas): 404.

4. Tamaño de un mensaje

¿Cuántos bytes mide el PUBLISH QoS 0 del tema planta/sensor01/temperatura con el dato 23.5?

Ver solución

El tema tiene 27 caracteres. Largo restante = 2 + 27 + 4 = 33 (cabe en un byte), y el paquete mide 1 + 1 + 33 = 35 bytes.

5. El signo

La temperatura medida es −0,4 °C. ¿Qué valor en décimas se obtiene y qué texto sale con el formato ingenuo y con el correcto?

Ver solución

−0,4 · 10 = −4 décimas. El ingenuo: −4 / 10 = 0 (el cociente entero pierde el signo) y el resto 4 → «0.4», que es incorrecto. El correcto toma el valor absoluto y agrega el signo aparte: «−0.4».

6. Latido y última voluntad

Con latido de 30 s, ¿cuánto tarda como máximo el broker en publicar la última voluntad tras un corte de energía del nodo? ¿Y si se usa un latido de 5 minutos?

Ver solución

El broker espera 1,5 veces el latido sin noticias: 45 s con 30 s, y 450 s (7,5 minutos) con 5 minutos. Un latido corto detecta antes la caída, a costa de más tráfico.

7. Desgaste

Un programa defectuoso reescribe el intervalo en la EEPROM una vez por minuto. ¿Cuánto tarda en agotar los 100 000 ciclos de la celda?

Ver solución

1440 escrituras por día: 100 000 / 1440 ≈ 69 días, unos dos meses y medio. Por eso se escribe solo cuando el valor cambia.

15 · Síntesis

Ideas para recordar

  • Un nodo de red se diseña desde afuera hacia adentro: primero los temas MQTT, el formato de los mensajes y las rutas web, porque son el contrato con el resto del sistema; después el código.
  • El programa se estructura como servicios cortos que nunca esperan (sensor, MQTT, web, mantenimiento de Ethernet), cada uno con su estado y su reloj. Los delay() largos congelan la red. Los tiempos se comparan con (long)(ahora − proximo) para resistir el desborde de millis().
  • La conexión MQTT se vigila: sin enlace no se intenta, con enlace se reintenta con retroceso exponencial (5, 10, 20, 40, 60 s) y, al conectar, se repiten suscripción y estado. Estado retenido + última voluntad + latido dicen en todo momento si el nodo está vivo.
  • Todo lo que llega por la red (MQTT o HTTP) se valida antes de usarse: solo dígitos, rango permitido, largo máximo. Dos entradas, una sola función de validación.
  • La temperatura se maneja en décimas enteras y se formatea con el signo aparte (−0,5 no es 0,5). El valor −127 de la biblioteca indica sensor desconectado.
  • La EEPROM aguanta unas 100 000 escrituras por celda: se guarda solo la configuración, solo cuando cambia, con un byte mágico para reconocer datos válidos.
  • El tráfico de un nodo es pequeño (≈ 270 KiB de MQTT, ≈ 1,6 MiB en el cable por día a 30 s), pero casi todo son encabezados; con flotas grandes o enlaces de pago conviene agrupar, alargar intervalos y comprimir el formato.
  • Se verifica desde afuera, por capas: sensor, enlace y DHCP, web con curl, MQTT con mosquitto_sub y mosquitto_pub, fallas inducidas (broker, cable, alimentación) y análisis con Wireshark. Un dispositivo desatendido necesita además perro guardián, seguridad y, según el caso, hora real y respaldo local.

¿Listo para el siguiente tema?

Antes de avanzar, explicá por qué el nodo no usa delay(800) para esperar al sensor (bloquearía la web y el latido); calculá la secuencia de esperas de la reconexión (5, 10, 20, 40, 60, 60 s); y decidí por qué la temperatura no se publica como mensaje retenido pero el estado sí (la temperatura caduca, el estado es el último hecho conocido). Si podés justificar las tres respuestas, dominás cómo se construye un dispositivo de red robusto.

Con este taller cerramos las redes cableadas. En los temas siguientes pasamos a las comunicaciones inalámbricas, empezando por los fundamentos de la comunicación inalámbrica (espectro, modulación, antenas, alcance y consumo) y siguiendo con Wi-Fi, donde lo que vimos de IP, TCP y MQTT se adapta a un medio compartido por radio.