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:
conectadoretenido al arrancar ydesconectadocomo última voluntad. - Recibir comandos: un mensaje MQTT con el intervalo nuevo.
- Servir una página web con el estado, una ruta
/apicon 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
| Cantidad | Elemento | Comentario |
|---|---|---|
| 1 | Placa 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 |
| 1 | Módulo Ethernet con chip W5500 | Los hay con regulador de 3,3 V y conector RJ45 con transformador integrado |
| 1 | Sensor de temperatura DS18B20 | En cápsula TO-92 o sonda impermeable |
| 1 | Resistencia de 4,7 kΩ | Pull-up de la línea de datos 1-Wire (tema 21) |
| 1 | Cable Ethernet y un conmutador o router de la red | Con servidor DHCP (casi todos los routers lo traen) |
| 1 | Una computadora con Mosquitto instalado | Hace de broker en la red local y de herramienta de prueba |
| Señal | Placa | Módulo / sensor |
|---|---|---|
| Reloj SPI | SCK (pin 52 en el Mega, 13 en el Uno) | SCLK del W5500 |
| Datos hacia el módulo | MOSI (51 en el Mega, 11 en el Uno) | MOSI |
| Datos desde el módulo | MISO (50 en el Mega, 12 en el Uno) | MISO |
| Selección | 10 | CS (SCS) |
| Reinicio (opcional) | — | RST (déjalo sin conectar o a 3,3 V según el módulo) |
| Datos 1-Wire | 2 | DQ del DS18B20, con 4,7 kΩ a la alimentación |
| Alimentación | 3,3 V (o 5 V según el módulo) y GND | VCC 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
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:
| Tema | Sentido | Contenido | QoS / retenido |
|---|---|---|---|
planta/sensor01/temperatura | nodo → broker | Texto: 23.5 | 0 / no |
planta/sensor01/lectura | nodo → broker | JSON: {"t":23.5,"n":2880,"up":86400} (temperatura, lecturas, segundos desde el arranque) | 0 / no |
planta/sensor01/estado | nodo → broker | conectado / desconectado (última voluntad) | retenido |
planta/sensor01/config/intervalo | broker → nodo | Segundos, de 5 a 3600: 45 | suscripción |
| Ruta | Respuesta | Qué hace |
|---|---|---|
GET / | 200, HTML | Página con la temperatura, los contadores y el formulario del intervalo |
GET /api | 200, JSON | {"id":"sensor01","t":23.5,"n":42,"fallas":0,"intervalo":30} |
GET /set?i=45 | 303 (o 400) | Cambia el intervalo y redirige a la página; 400 si el valor es inválido |
| cualquier otra | 404 | No 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
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
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:
- Si el enlace físico está caído (
Ethernet.linkStatus() == LinkOFF), no se intenta nada; se espera a que vuelva. - Si no está conectado, el servicio intenta
mqtt.connect()cuando llega el momento programado. - 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.
- Al conectar, publica
conectadoretenido 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: closey 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 que45xse 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:
- 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.
- 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».
- Web. Abrir
http://IP-del-nodo/en el navegador y/apiconcurl. Probar/set?i=45y/set?i=2(debe dar 400) y una ruta inexistente (404). - MQTT. Con Mosquitto activo, suscribirse con
mosquitto_sub -h IP-DEL-BROKER -t "planta/#" -v -Ry ver el estado, la temperatura y el JSON. - Comandos. Publicar
mosquitto_pub -h IP-DEL-BROKER -t planta/sensor01/config/intervalo -m 10y comprobar que las lecturas llegan cada 10 s. Probar valores inválidos (2,abc,3601): no deben cambiar nada. - Persistencia. Cambiar el intervalo, quitar la alimentación, volver a conectarla: debe arrancar con el valor guardado.
- 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). - 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(" °C")); } else c.print(F("sin lectura"));
c.print(F("</p><p>Lecturas: ")); c.print(lecturas);
c.print(F(" · 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:
| Qué se prueba | Comando | Resultado esperado |
|---|---|---|
| Que el nodo responde en la red | ping 192.168.1.50 | Respuestas del nodo (el W5500 responde ICMP) |
| La página principal | curl -i http://192.168.1.50/ | HTTP/1.1 200 OK y el HTML con la temperatura |
| La API | curl http://192.168.1.50/api | {"id":"sensor01","t":23.5,"n":12,"fallas":0,"intervalo":30} |
| Cambio válido | curl -i "http://192.168.1.50/set?i=45" | 303 See Other con Location: / |
| Cambio inválido | curl -i "http://192.168.1.50/set?i=2" | 400 Bad Request; el intervalo no cambia |
| Todo el tráfico MQTT del nodo | mosquitto_sub -h 192.168.1.10 -t "planta/#" -v | Estado retenido, temperatura y JSON cada intervalo |
| Un comando | mosquitto_pub -h 192.168.1.10 -t planta/sensor01/config/intervalo -m 10 | Las lecturas pasan a llegar cada 10 s |
| La última voluntad | Cortar la alimentación del nodo y mirar el suscriptor | Hasta 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
Elegí cuánto dura la caída del broker.
2. Rutas del servidor web
Escribí una línea de petición.
3. Tráfico diario
Elegí el intervalo y la cantidad de nodos.
4. Formato de la temperatura
Escribí una temperatura.
5. Comando de intervalo
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
- 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.
- 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).
- 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).
- Probá GET /set?i=45x HTTP/1.1: se acepta como 45 porque
atolignora lo que sigue. Es un defecto menor, pero muestra por qué conviene validar el texto completo, como hace el comando MQTT. - 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. - 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).
- 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 demillis(). - 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.