01 · Objetivo
Qué vamos a construir
Un sistema de monitoreo de una planta con estos requisitos, escritos con números como pide el tema 50:
| Criterio | Requisito |
|---|---|
| Nodos | 8 hoy, hasta 30 en el futuro; cada uno con temperatura, humedad y tensión de alimentación |
| Distancia | El nodo más lejano a 300 m de la puerta de enlace, por un solo cable |
| Datos | Una lectura por nodo cada 10 s (9 bytes), en MQTT con un tema por nodo |
| Entorno | Industrial: motores y variadores cerca del cable; los nodos tienen alimentación local |
| Disponibilidad | Detectar un nodo caído en menos de 1 minuto sin falsas alarmas; seguir funcionando si se cae el Wi-Fi o el broker durante algunos minutos, sin perder lecturas |
| Configuración | Cambiar el período de medición de un nodo desde el broker, sin ir hasta el nodo |
| Observabilidad | Contadores de cada capa para distinguir un problema del cable, del nodo, de la red o del broker |
| Seguridad | Credenciales y TLS en la conexión con el broker; autenticación de las tramas del bus como ampliación (tema 49) |
Cada requisito se traduce en una decisión de diseño y en una prueba. Los nodos y la puerta de enlace comparten el mismo protocolo, y cada capa del curso aparece en un lugar preciso del proyecto:
| Tema | Dónde se usa |
|---|---|
| 22 a 24 RS-485 | El medio físico entre los nodos y la puerta de enlace: par trenzado, terminación, polarización, DE/RE |
| 41 y 42 Protocolo y paquetes | La trama [A5][LEN][DST][SRC][TIPO][datos][CRC], la máquina de estados de recepción y las direcciones de cada nodo |
| 43 y 44 Errores y CRC | El CRC-16 de cada trama y la validación de lo recibido |
| 45 Confirmaciones y reintentos | La secuencia que liga cada respuesta a su consulta, los tiempos de espera y los reintentos; el ACK de la configuración |
| 46 Formatos | El registro de 9 bytes serializado campo por campo, el JSON de MQTT y la validación de rangos |
| 32, 33 y 36 MQTT y Wi-Fi | La publicación, los temas, el estado retenido, la última voluntad, la reconexión con retroceso |
| 48 Depuración | El analizador sobre el bus, mosquitto_sub sobre el broker y los contadores de cada capa |
| 49 Seguridad | TLS, usuarios y permisos en el broker; HMAC y contador en las tramas del bus como ampliación |
| 50 Selección | Por qué RS-485 y no otra, y el presupuesto de tiempo y de tráfico |
02 · Decisiones
La arquitectura y por qué
Las decisiones, con la razón de cada una (el método del tema 50 aplicado):
- RS-485 para el campo. 300 m y 8 a 30 nodos, 1 lectura de 9 bytes por nodo cada 10 s (ocupación del bus de 1,3 %) y un entorno con variadores: la herramienta 1 del tema 50 deja RS-485 y CAN, y RS-485 es más simple y barato. Por cable, los nodos se alimentan localmente y no se preocupan por la batería.
- Un protocolo propio y no Modbus. Modbus RTU habría servido (tema 26) y sería la elección si hubiera equipos ya instalados que lo hablan. Acá se usa la trama de los temas 41 a 47 por dos razones didácticas: reúne en un solo lugar el CRC, la secuencia, los reintentos y el formato del registro que se estudiaron, y permite agregar sin cambiar nada la autenticación del tema 49. El diseño en capas permite cambiar de protocolo de bus sin tocar la parte de MQTT.
- Sondeo y no eventos. El maestro pregunta a cada nodo por turno. Elimina las colisiones, permite medir qué nodos están vivos (un nodo que no contesta es un hecho observable) y mantiene todo el control del tiempo en un solo lugar. Cuesta que una alarma espere su turno (como máximo una vuelta de 132 ms), irrelevante con lecturas cada 10 s.
- Una puerta de enlace con el bus y la red en el mismo programa. Un solo ESP32 hace de maestro y de cliente MQTT, con la regla de que ninguna parte espera: todo se hace con máquinas de estados y tiempos de vencimiento en el
loop()(tema 36). - MQTT hacia arriba. Un tema por nodo con JSON, el estado retenido y la última voluntad. Desacopla al que produce de quien consume: el panel, el registro y las alarmas se suscriben sin que la puerta de enlace los conozca.
03 · Materiales y cableado
Materiales y cableado
| Cantidad | Componente | Nota |
|---|---|---|
| 9 | Placas ESP32 (8 nodos y la puerta de enlace) | Para probar en la mesa alcanzan 3 (la puerta de enlace y 2 nodos); el programa de los nodos es el mismo con otra dirección |
| 9 | Transceptores RS-485 de 3,3 V (MAX3485, SP3485 o módulos equivalentes) | Un MAX485 de 5 V no se conecta directo a un ESP32 (tema 24) |
| Según el tendido | Cable de par trenzado (de red, categoría 5, un par para A y B) | Un solo cable recorre todos los nodos en cadena |
| 2 | Resistencias de 120 Ω | Una en cada extremo del bus, y solo ahí |
| 2 | Resistencias de polarización | En un solo punto (la puerta de enlace), para que la línea en reposo tenga un nivel definido (tema 24) |
| 9 | Condensadores de desacoplo de 100 nF | Cerca de la alimentación de cada transceptor |
| 1 | Una computadora con Mosquitto y un cliente MQTT | El broker. mosquitto_sub y mosquitto_pub para probar |
| Opcional | Analizador lógico (o un adaptador USB–RS-485 en escucha) | Para ver el bus (tema 48) |
| Desde | Hasta | Función |
|---|---|---|
| GPIO 17 (TX1) | DI del transceptor | Datos que salen al bus |
| GPIO 16 (RX1) | RO del transceptor | Datos que llegan del bus |
| GPIO 4 | DE y RE, unidos | Alto = transmitir; bajo = escuchar. Arranca en bajo |
| 3,3 V y GND | VCC y GND del transceptor | Alimentación |
| A y B del transceptor | Par trenzado del bus | Los mismos A y B en todos los nodos: un nodo con A y B invertidos no se entiende |
| Masa de todos | Referencia común (tercer hilo o malla) | El rango de modo común del RS-485 es de −7 a +12 V (tema 23) |
Reglas de tendido que aparecen en las fallas de la sección 7: un solo recorrido en cadena (no estrella): las derivaciones a cada nodo, lo más cortas posible (menos de 0,5 m); terminación de 120 Ω en los dos extremos físicos del cable, no en los nodos intermedios; par trenzado para A y B, separado de los cables de potencia, y cruzando los de los motores en ángulo recto; blindaje, si lo hay, a tierra en un solo extremo. A 19 200 baudios y 300 m el margen es grande: el límite de la regla de velocidad por distancia es 1200 m a 100 kbit/s (tema 50, herramienta 3).
04 · La conversación
El protocolo del bus
La trama es la de los temas 44 a 47: [SOF 0xA5][LEN][DST][SRC][TIPO][datos][CRC-16 bajo][CRC-16 alto], con el CRC-16/Modbus sobre LEN hasta el último dato. Las direcciones: los nodos son 1 a 8; la puerta de enlace es la 100. Hay cuatro tipos de mensaje, y la conversación siempre empieza la puerta de enlace (nadie habla sin que se le pregunte):
| Tipo | Sentido | Datos | Para qué |
|---|---|---|---|
| 0x02 PETICIÓN | Puerta de enlace → nodo | secuencia (1 byte) | «Dame tu última lectura» |
| 0x01 LECTURA | Nodo → puerta de enlace | secuencia de la consulta, versión, nodo, temperatura (int16, centésimas), humedad (uint16, centésimas), tensión (uint16, mV): 9 bytes | La respuesta. Lleva la secuencia de la consulta: la puerta de enlace solo acepta la que espera |
| 0x03 CONFIG | Puerta de enlace → nodo | secuencia y período en segundos (uint16): 3 bytes | Cambiar el período de medición |
| 0x41 ACK | Nodo → puerta de enlace | la secuencia | Confirma que el período se aceptó (entre 1 y 3600 s) |
| Mensaje | Trama |
|---|---|
| Consulta al nodo 1, secuencia 1 | A5 04 01 64 02 01 55 43 |
| Respuesta del nodo 1: 23,50 °C, 55,00 %, 3300 mV | A5 0C 64 01 01 01 01 01 2E 09 7C 15 E4 0C 73 D3 |
| Configurar el nodo 3 a 30 s, secuencia 2 | A5 06 03 64 03 02 1E 00 A4 DF |
| Confirmación del nodo 3 a esa configuración | A5 04 64 03 41 02 8B 61 |
El ciclo de sondeo es una vuelta cada 10 s: la puerta de enlace consulta al nodo 1, espera su respuesta hasta 40 ms, reintenta hasta dos veces, pasa al 2, y así hasta el 8; después espera a que se cumpla el período. El tiempo de cada paso sale de la cuenta de la herramienta 1: a 19 200 baudios, la consulta de 8 bytes dura 4,17 ms, la respuesta de 16 bytes, 8,33 ms, y con 4 ms de vuelta del transceptor y de proceso, un nodo cuesta 16,5 ms. Una vuelta de 8 nodos son 132 ms, un 1,3 % del período; con un nodo caído, cada vuelta suma 120 ms (3 intentos de 40 ms) y, en el peor caso, con todos los nodos caídos, 960 ms: sigue cabiendo con mucha holgura. La espera de 40 ms es 2,4 veces el viaje de un nodo, dentro de la regla del tema 45.
Tres decisiones del protocolo merecen atención. La secuencia liga la respuesta a la consulta: si un nodo contesta tarde (después de que la puerta de enlace ya pasó al siguiente), su respuesta llega con una secuencia vieja y se descarta, en lugar de interpretarse como la del nodo actual. El nodo mide en segundo plano y contesta con lo que tiene en memoria: la consulta se atiende en milisegundos, sin esperar a que el sensor termine (la regla del tema 26). La configuración se acepta o se ignora, nunca se aplica a medias: un período fuera de rango (0 o 5000) no se confirma, y la puerta de enlace reintenta y finalmente lo reporta.
05 · El corazón
La puerta de enlace por dentro
El programa de la puerta de enlace tiene cinco piezas, y cada una responde a una pregunta:
- El decodificador toma los bytes del bus y arma tramas con la máquina de estados del tema 41. No decide nada: entrega las tramas con CRC correcto a
alPaquete. alPaquetedecide si una trama es la respuesta que se esperaba: destino y origen correctos, tipo coherente con la consulta, la misma secuencia. Una lectura con valores imposibles (una temperatura de 120 °C, una versión desconocida) se cuenta como inválida y se ignora, y el nodo seguirá sin contestar a ojos de la puerta de enlace. Una respuesta válida se guarda en la cola.sondear()es la máquina de estados del maestro (el estilo del tema 47): si no hay una consulta en curso, la envía; si venció el temporizador, reintenta o se rinde y cuenta un fallo para ese nodo; al terminar la vuelta, espera a que se cumpla el período. No haydelay(). Cuando un nodo acumulaUMBRAL_FALLOSvueltas seguidas sin respuesta, se lo declara caído y se encola un evento de estado; cuando vuelve a contestar, se lo declara en línea.- La cola circular de 64 entradas separa a quien produce (el bus, al ritmo de las vueltas) de quien consume (MQTT, que puede estar caído). Si se llena, descarta la entrada más vieja y cuenta la pérdida. Lecturas y eventos de estado van en la misma cola, en orden: el panel ve primero la lectura y después el cambio de estado, como ocurrieron.
atenderConexion()ypublicarUna()mantienen el Wi-Fi y MQTT. Si el broker no contesta, el intento se repite con retroceso exponencial (5, 10, 20, 40 y 60 s) y se acota a 2 s de bloqueo consetSocketTimeout; mientras tanto el sondeo sigue. Con conexión, se publica una entrada por vuelta delloop(), lo que vacía rápido una cola atrasada y no ocupa el procesador. Cada lectura lleva su edad (cuánto hace que se midió): un dato publicado minutos después no se confunde con uno actual.
La regla que organiza el programa es la del tema 33: un bucle que nunca espera. Es lo que permite que un corte del Wi-Fi, un broker lento o un nodo que no contesta no detengan nada más. El costo es el estado explícito (qué nodo se consulta, cuántos intentos van, cuándo vence el temporizador), que se escribe una vez y se prueba bien.
06 · Hacia arriba
MQTT: temas, estados y comandos
| Tema | Sentido | Contenido | Retenido |
|---|---|---|---|
planta/nodo01/lectura | pasarela → broker | JSON: {"t":23.5,"h":55.0,"bat":3300,"seq":17,"edad":0} (temperatura en °C, humedad en %, tensión en mV, secuencia y segundos desde la medición) | No: un dato viejo no es el estado actual |
planta/nodo01/estado | pasarela → broker | en_linea o fuera_de_linea | Sí: es el último hecho conocido |
planta/pasarela/estado | pasarela → broker | en_linea; la última voluntad es fuera_de_linea, que el broker publica si la conexión se corta sin despedirse | Sí |
planta/pasarela/stats | pasarela → broker | JSON cada 60 s: vueltas, respuestas, tiempos agotados, errores de CRC, inválidas, entradas en cola, descartadas | No |
planta/nodo03/config/periodo | broker → pasarela | Segundos, de 1 a 3600: 30 | No (es una orden) |
Las decisiones de diseño siguen las del tema 33. Un tema por nodo (y no por magnitud) permite dar permisos por nodo, suscribirse a uno o a todos con planta/+/lectura, y que el estado de cada uno sea retenido. La temperatura no se retiene pero el estado sí: un tablero que se conecta a las tres de la mañana ve de inmediato qué nodos están vivos, aunque la lectura de cada uno todavía tarde unos segundos. La última voluntad de la puerta de enlace es la defensa contra el fallo más peligroso del sistema, el silencioso: si la puerta de enlace muere, los nodos no cambian de estado (nadie los sondea) pero el broker publica fuera_de_linea en el tema de la puerta de enlace. Quien mira el panel sabe entonces que las lecturas dejaron de llegar por la pasarela y no por los nodos.
El tráfico de MQTT es chico pero casi todo es gasto (la herramienta 2): con 8 nodos cada 10 s y un JSON por nodo, son 65 bytes de MQTT por lectura, 4,5 MB al día de contenido MQTT y unos 10 MB en el cable contando los encabezados de red. Con 100 nodos, 125 MB por día. Agrupar todas las lecturas en un solo mensaje por vuelta lo reduce a 3,7 MB con 8 nodos, a costa del tema por nodo. Con 8 nodos no vale la pena; con 100 y un enlace de pago, sí.
El comando sigue un camino de tres tramos: el broker entrega 30 en planta/nodo03/config/periodo; alMensaje valida el nodo y el rango y deja el valor pendiente; en la vuelta siguiente, sondear() sustituye la consulta al nodo 3 por un mensaje CONFIG, y espera su ACK. Si el ACK llega, el pendiente se borra; si no, se reintenta (con los mismos tiempos y reintentos que una consulta) y, si se rinde, queda pendiente para la vuelta siguiente. El comando es idempotente (aplicarlo dos veces deja el mismo estado), de modo que repetirlo es seguro (tema 45).
07 · Cuando algo falla
Fallas, seguridad y qué mide cada contador
Cada falla de la tabla deja un rastro característico en los contadores. Es la tabla del tema 48 aplicada a este sistema:
| Falla | Qué se ve | Dónde |
|---|---|---|
| Un nodo se apaga o se desconecta | El nodo no contesta tras los reintentos: tras UMBRAL_FALLOS vueltas, estado pasa a fuera_de_linea; las vueltas duran 120 ms más | Tema estado del nodo; tiempos sube de a 3 por vuelta |
| Ruido en el bus | Tramas con CRC incorrecto en la puerta de enlace; tiempos agotados esporádicos; los reintentos lo absorben | crc y tiempos en stats; el contador del nodo (errores en su monitor serie) |
| A y B invertidos en un nodo | Ese nodo nunca contesta (sus tramas llegan invertidas) o la puerta de enlace ve basura | Un solo nodo caído desde la instalación |
| DE que baja antes de que salga el último bit | El último byte de la respuesta llega cortado: CRC erróneo en todas las respuestas de ese nodo | crc sube una vez por consulta a ese nodo (corregir con flush()) |
| Falta de terminación o derivaciones largas | Errores que aumentan con la velocidad y con la distancia del nodo | crc repartido entre los nodos más lejanos |
| Un sensor averiado | El nodo contesta, pero con valores imposibles: la lectura se rechaza | invalidas sube; el nodo, a la larga, pasa a fuera_de_linea |
| Se cae el Wi-Fi o el broker | El sondeo sigue; la cola se llena y, si el corte es largo, descarta lo más viejo; al volver, todo se publica con su edad | cola y descartadas en stats; pasarela/estado por la última voluntad |
| Se cae la puerta de enlace | Dejan de llegar lecturas de todos los nodos a la vez; la última voluntad publica fuera_de_linea | planta/pasarela/estado |
Una relación de diagnóstico, como en el tema 47: si en un nodo el contador consultas (que cuenta las peticiones válidas que recibió) es mayor que las respuestas que cuenta la puerta de enlace, las respuestas se pierden en el camino de vuelta; si es menor, las consultas se pierden en el de ida. Si crc en la puerta de enlace es cero y tiempos es alto, las respuestas no llegan ni dañadas: el nodo no transmite o su DE no se activa.
Qué falta para que sea seguro
Como está, el proyecto es funcional pero no seguro: cualquiera con acceso al cable puede escuchar, falsificar una lectura (recalcula el CRC) o repetir una configuración. Lo que el tema 49 agrega, y dónde:
- Entre la puerta de enlace y el broker: TLS en el puerto 8883 con verificación del certificado (nunca
setInsecure()), un usuario y una contraseña propios de la puerta de enlace, y permisos en el broker que le permitan publicar solo enplanta/#y leer soloplanta/+/config/#. Es lo primero, porque la red de la planta es más abierta que el bus. - En el bus: agregar a cada trama un contador de 32 bits y una etiqueta HMAC de 8 bytes (el módulo de la sección 9 del tema 49), con una clave por nodo derivada de una maestra. Cuesta 12 bytes por trama (a 19 200 baudios, 6,3 ms más por cada una): si se autentican la consulta y la respuesta, el viaje de un nodo sube de 16,5 a unos 29 ms, y una vuelta de 8 nodos, a 232 ms; sigue siendo poco frente a los 10 s. Convierte al bus de «confiable» en «autenticado».
- El comando de configuración es la orden más peligrosa, porque cambia el comportamiento de un nodo: debe llegar autenticado y con permiso en el broker; solo la puerta de enlace y un usuario de administración pueden publicarla.
- Acceso físico: el puerto de depuración y la memoria de la puerta de enlace contienen las credenciales de MQTT y, con la ampliación, las claves de los nodos: cifrado de flash y arranque seguro.
08 · Verificar con fallas
Plan de pruebas
Se construye y se prueba por etapas, y no se pasa a la siguiente hasta que la anterior da lo esperado. Cada prueba provoca una falla a propósito y tiene el resultado calculado de antemano. Los valores son para los parámetros del código (19 200 baudios, espera de 40 ms, 2 reintentos, vuelta cada 10 s, umbral de 3 vueltas, cola de 64).
| # | Prueba | Qué se espera | Si no ocurre |
|---|---|---|---|
| 1 | Un nodo y la puerta de enlace en la mesa (NODOS = 1), sin Wi-Fi todavía. Mirar el bus con el analizador | Cada 10 s, una consulta A5 04 01 64 02 sq CRC y la respuesta de 16 bytes. Viaje de unos 16 a 17 ms; ninguna espera agotada; en el monitor del nodo, consultas igual a las vueltas | Cables A y B, DE/RE, dirección, masa, velocidad (tema 48) |
| 2 | Ocho nodos, bus limpio | Una vuelta de unos 132 ms; tiempos y crc en cero; 8 lecturas por vuelta | Un nodo con la dirección repetida (dos responden a la vez y se pisan) o con A y B invertidos |
| 3 | Conectar a Mosquitto: mosquitto_sub -v -t 'planta/#' | Las lecturas de los 8 nodos en planta/nodoNN/lectura, con edad 0; planta/pasarela/estado en en_linea; el estado retenido de cada nodo | Credenciales de Wi-Fi, dirección del broker, puerto, el tema mal escrito |
| 4 | Desconectar un nodo (quitar su cable) | Cada vuelta dura 120 ms más. A la tercera vuelta, planta/nodoNN/estado pasa a fuera_de_linea (en unos 30 s). Al reconectarlo, vuelve a en_linea en la vuelta siguiente | Sin el estado: el umbral o la detección de fallos no está contando |
| 5 | Ruido en el bus: sacar una terminación de 120 Ω, o pasar el cable junto a un motor | Aparecen errores de CRC y esperas agotadas; los reintentos los absorben y el número de lecturas perdidas es mucho menor que el de errores. Con la terminación puesta, vuelven a cero | Si cada error pierde una lectura, los reintentos no funcionan: revisar la secuencia y el temporizador |
| 6 | Cortar el Wi-Fi durante 1 minuto (apagar el punto de acceso) | El sondeo no se detiene (se ve en el analizador). La cola llega a unas 48 entradas (6 vueltas por 8 nodos). Al volver, se publican todas, en orden, con edad creciente hasta 60 s; descartadas se mantiene en 0 | Si el sondeo se detiene, algo bloquea (un delay() o un intento de conexión largo) |
| 7 | Cortar el Wi-Fi durante 3 minutos | Se generan 144 entradas y caben 64: descartadas sube a unas 80, y las que se publican al volver son las más nuevas (la edad máxima ronda los 80 s). Coincide con la herramienta 4: para cubrir 3 minutos hacen falta 144 entradas | Si se pierden las nuevas y se guardan las viejas, el búfer descarta mal |
| 8 | Reiniciar el broker | La puerta de enlace detecta la caída, reintenta con esperas de 5, 10, 20 s, reconecta, vuelve a suscribirse y vuelve a publicar el estado de cada nodo. Durante la caída, el sondeo sigue y la cola crece | Si no se resuscribe, los comandos dejan de llegar tras la reconexión |
| 9 | Cambiar el período: mosquitto_pub -t planta/nodo03/config/periodo -m 30 | En una o dos vueltas, el monitor del nodo 3 informa «período 30 s» y el contador de configuraciones sube; el pendiente se borra tras el ACK. Con -m 5000 (fuera de rango): el comando se ignora en la puerta de enlace | Si el nodo no confirma, revisar la secuencia del CONFIG y el rango |
| 10 | Valores imposibles: forzar actual.t = 12000 en un nodo | invalidas sube; no se publica ninguna lectura de ese nodo; tras 3 vueltas, pasa a fuera_de_linea | Si se publica 120 °C, falta la validación |
| 11 | Apagar la puerta de enlace | El broker publica planta/pasarela/estado = fuera_de_linea (la última voluntad) a los pocos segundos de detectar la desconexión (según el tiempo de latido del broker) | Si no aparece, la última voluntad no se registró al conectar |
Las pruebas 6 y 7 son las más reveladoras: muestran el dimensionamiento del búfer con una cuenta (herramienta 4) y la política de descartar lo más viejo. Y las 4 y 5 verifican la idea central del diseño: un solo error del bus no debe cambiar el estado de nadie ni perder una lectura, y una falla real debe verse a los 30 s.
09 · El código completo
El nodo y la puerta de enlace
Hay dos programas. El nodo es un esclavo mínimo: escucha el bus, contesta lo que le toca y sabe medir en segundo plano. La puerta de enlace contiene el maestro, la cola y el cliente MQTT. Los dos comparten el CRC, el formato de la trama y el decodificador de los temas 41 a 47.
El nodo (uno por cada dirección de 1 a 8)
// ---------- Configuración del nodo ----------
const uint8_t ID_NODO = 1; // dirección en el bus: 1 a 8 (distinta en cada nodo)
const uint8_t DIR_PASARELA = 100;
const uint8_t PIN_DE = 4; // DE y RE del transceptor, unidos: alto = transmitir
const uint32_t BAUDIOS = 19200;
const uint8_t SOF = 0xA5, MAX_LEN = 35;
const uint8_t TIPO_LECTURA = 0x01, TIPO_PETICION = 0x02, TIPO_CONFIG = 0x03, TIPO_ACK = 0x41;
const uint8_t VERSION = 1;
const uint32_t TIEMPO_BYTE_MS = 10;
// ---------- CRC-16/Modbus (tema 44) ----------
uint16_t crc16(const uint8_t *d, size_t n) {
uint16_t crc = 0xFFFF;
while (n--) {
crc ^= *d++;
for (uint8_t i = 0; i < 8; i++) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
return crc;
}
// ---------- El registro (tema 46) ----------
struct Registro { uint8_t id; int16_t t; uint16_t h; uint16_t bat; };
Registro actual;
uint16_t periodoS = 10; // período de medición, configurable por el bus
unsigned long ultimaMedicion = 0;
uint32_t peticiones = 0, configuraciones = 0;
void poner16(uint8_t *p, uint16_t v) { p[0] = v & 0xFF; p[1] = v >> 8; }
void medir() { // sensor simulado; se cambia por el real
actual.id = ID_NODO;
actual.t = 2000 + ID_NODO * 40 + (int16_t)(200 * sin(millis() / 30000.0));
actual.h = 5000 + random(-100, 100);
actual.bat = 3300;
}
// ---------- Envío: el transmisor se habilita solo mientras habla ----------
void enviar(uint8_t dst, uint8_t tipo, const uint8_t *datos, uint8_t n) {
uint8_t p[MAX_LEN + 4];
p[0] = SOF;
p[1] = n + 3;
p[2] = dst;
p[3] = ID_NODO;
p[4] = tipo;
memcpy(p + 5, datos, n);
uint16_t c = crc16(p + 1, n + 4);
p[5 + n] = c & 0xFF;
p[6 + n] = c >> 8;
delay(2); // la pasarela necesita tiempo para pasar a recibir
digitalWrite(PIN_DE, HIGH);
Serial1.write(p, n + 7);
Serial1.flush(); // hasta que sale el último bit
digitalWrite(PIN_DE, LOW);
}
void alPaquete(uint8_t dst, uint8_t src, uint8_t tipo, const uint8_t *d, uint8_t n) {
if (dst != ID_NODO) return; // el bus es de todos: solo se contesta lo propio
if (tipo == TIPO_PETICION && n >= 1) {
peticiones++;
uint8_t r[9] = { d[0], VERSION, actual.id, 0, 0, 0, 0, 0, 0 }; // la secuencia de la consulta vuelve en la respuesta
poner16(r + 3, (uint16_t)actual.t);
poner16(r + 5, actual.h);
poner16(r + 7, actual.bat);
enviar(src, TIPO_LECTURA, r, sizeof(r));
} else if (tipo == TIPO_CONFIG && n >= 3) {
uint16_t nuevo = d[1] | (d[2] << 8);
if (nuevo >= 1 && nuevo <= 3600) { // se valida: un valor imposible no se acepta ni se confirma
periodoS = nuevo;
configuraciones++;
enviar(src, TIPO_ACK, d, 1);
}
}
}
// ---------- Decodificador de tramas (tema 41, con CRC-16) ----------
class Decodificador {
public:
uint32_t errores = 0;
void alimentar(uint8_t b) {
ultimoByte = millis();
switch (estado) {
case ESPERA_SOF: if (b == SOF) estado = ESPERA_LEN; break;
case ESPERA_LEN:
if (b >= 3 && b <= MAX_LEN) { buf[0] = b; n = 1; faltan = b + 2; estado = CUERPO; }
else { errores++; estado = ESPERA_SOF; alimentar(b); }
break;
case CUERPO:
buf[n++] = b;
if (--faltan == 0) {
estado = ESPERA_SOF;
if (crc16(buf, n) == 0) alPaquete(buf[1], buf[2], buf[3], buf + 4, n - 6);
else {
errores++;
uint8_t copia[MAX_LEN + 3];
uint8_t m = n;
memcpy(copia, buf, m);
n = 0;
for (uint8_t i = 0; i < m; i++) alimentar(copia[i]);
}
}
break;
}
}
void vigilarTiempo() {
if (estado != ESPERA_SOF && millis() - ultimoByte > TIEMPO_BYTE_MS) { errores++; estado = ESPERA_SOF; n = 0; }
}
private:
enum Estado { ESPERA_SOF, ESPERA_LEN, CUERPO } estado = ESPERA_SOF;
uint8_t buf[MAX_LEN + 3];
uint8_t n = 0, faltan = 0;
unsigned long ultimoByte = 0;
};
Decodificador decodificador;
void setup() {
Serial.begin(115200);
Serial1.begin(BAUDIOS, SERIAL_8N1, 16, 17); // RX = GPIO 16 (RO), TX = GPIO 17 (DI)
pinMode(PIN_DE, OUTPUT);
digitalWrite(PIN_DE, LOW); // arranca escuchando
medir();
}
void loop() {
while (Serial1.available()) decodificador.alimentar(Serial1.read());
decodificador.vigilarTiempo();
if (millis() - ultimaMedicion >= periodoS * 1000UL) { // la medición va en segundo plano: la respuesta sale de la memoria
ultimaMedicion = millis();
medir();
}
static unsigned long ultimoInforme = 0;
if (millis() - ultimoInforme >= 30000) {
ultimoInforme = millis();
Serial.printf("nodo %u: %lu consultas, %lu configuraciones, %lu tramas con error, período %u s\n", ID_NODO, peticiones, configuraciones, decodificador.errores, periodoS);
}
}
- La dirección es lo único que cambia entre nodos (
ID_NODO). Sin DIP switches, cada placa se carga con su número; en un producto, se lee de un banco de interruptores o de la memoria no volátil. DEsube justo antes de transmitir y baja después deflush(): si se bajara antes de que salga el último bit, la respuesta llegaría cortada. Eldelay(2)previo da tiempo a que la puerta de enlace pase a recibir.- La medición está desacoplada de la respuesta: el nodo mide cada
periodoSsegundos y contesta con la última medición de la memoria. Una consulta se atiende en milisegundos aunque el sensor tarde cientos. - La configuración se valida antes de aceptarla y de confirmarla: un valor fuera de 1 a 3600 se ignora sin ACK.
- El nodo cuenta consultas, configuraciones y tramas con error, y los informa cada 30 s por el monitor serie: son el otro extremo de las cuentas de la puerta de enlace.
La puerta de enlace
#include <WiFi.h>
#include <PubSubClient.h>
// ---------- Configuración ----------
const char *WIFI_SSID = "mi-red";
const char *WIFI_CLAVE = "mi-clave";
const char *MQTT_SERVIDOR = "192.168.1.10"; // el broker (Mosquitto)
const uint16_t MQTT_PUERTO = 1883; // en un proyecto real: 8883 con TLS (tema 49)
const uint8_t NODOS = 8; // direcciones 1 a NODOS
const uint8_t DIR_PASARELA = 100;
const uint8_t PIN_DE = 4;
const uint32_t BAUDIOS = 19200;
const uint32_t PERIODO_MS = 10000; // una vuelta completa cada 10 s
const uint32_t ESPERA_MS = 40; // unas 2,4 veces el viaje de ida y vuelta
const uint8_t MAX_REINTENTOS = 2;
const uint8_t UMBRAL_FALLOS = 3; // vueltas seguidas sin respuesta para declarar un nodo caído
const uint8_t CAPACIDAD = 64; // entradas que se guardan si no hay broker
const uint8_t SOF = 0xA5, MAX_LEN = 35;
const uint8_t TIPO_LECTURA = 0x01, TIPO_PETICION = 0x02, TIPO_CONFIG = 0x03, TIPO_ACK = 0x41;
const uint8_t VERSION = 1;
const uint32_t TIEMPO_BYTE_MS = 10;
uint16_t crc16(const uint8_t *d, size_t n) {
uint16_t crc = 0xFFFF;
while (n--) {
crc ^= *d++;
for (uint8_t i = 0; i < 8; i++) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
return crc;
}
// ---------- El bus RS-485: la pasarela es el maestro ----------
void enviar(uint8_t dst, uint8_t tipo, const uint8_t *datos, uint8_t n) {
uint8_t p[MAX_LEN + 4];
p[0] = SOF; p[1] = n + 3; p[2] = dst; p[3] = DIR_PASARELA; p[4] = tipo;
memcpy(p + 5, datos, n);
uint16_t c = crc16(p + 1, n + 4);
p[5 + n] = c & 0xFF; p[6 + n] = c >> 8;
digitalWrite(PIN_DE, HIGH);
Serial1.write(p, n + 7);
Serial1.flush();
digitalWrite(PIN_DE, LOW); // vuelve a escuchar de inmediato
}
// ---------- La cola de lecturas y eventos que esperan para publicarse ----------
struct Entrada { bool esEstado; uint8_t nodo; bool enLinea; int16_t t; uint16_t h; uint16_t bat; uint8_t seq; uint32_t tms; };
Entrada cola[CAPACIDAD];
uint8_t cabeza = 0, cuenta = 0;
uint32_t descartadas = 0;
void encolar(const Entrada &e) {
if (cuenta == CAPACIDAD) { cabeza = (cabeza + 1) % CAPACIDAD; cuenta--; descartadas++; } // lleno: se pierde la más vieja
cola[(cabeza + cuenta) % CAPACIDAD] = e;
cuenta++;
}
// ---------- Estado de cada nodo y del sondeo ----------
uint8_t fallos[NODOS + 1];
bool enLinea[NODOS + 1];
uint16_t configPendiente[NODOS + 1]; // 0 = nada que enviar
uint8_t seq = 0, nodoActual = 1, intento = 0;
bool esperando = false, enConfig = false;
unsigned long venceEn = 0, inicioVuelta = 0;
uint32_t vueltas = 0, respuestas = 0, tiempos = 0, invalidas = 0;
void marcarFallo(uint8_t n) {
if (++fallos[n] == UMBRAL_FALLOS && enLinea[n]) {
enLinea[n] = false;
Entrada e = { true, n, false, 0, 0, 0, 0, millis() };
encolar(e);
}
}
void alPaquete(uint8_t dst, uint8_t src, uint8_t tipo, const uint8_t *d, uint8_t n) {
if (dst != DIR_PASARELA || !esperando || src != nodoActual || n < 1 || d[0] != seq) return; // solo la respuesta que se espera
if (tipo == TIPO_LECTURA && !enConfig && n >= 9) {
int16_t t = (int16_t)(d[3] | (d[4] << 8));
uint16_t h = d[5] | (d[6] << 8), bat = d[7] | (d[8] << 8);
if (d[1] != VERSION || t < -4000 || t > 8500 || h > 10000 || bat < 1800 || bat > 5500) { invalidas++; return; } // CRC bueno, valores imposibles
esperando = false;
respuestas++;
fallos[nodoActual] = 0;
Entrada e = { false, nodoActual, true, t, h, bat, d[0], millis() };
encolar(e);
if (!enLinea[nodoActual]) {
enLinea[nodoActual] = true; // volvió: se publica el cambio de estado
Entrada v = { true, nodoActual, true, 0, 0, 0, 0, millis() };
encolar(v);
}
nodoActual++;
} else if (tipo == TIPO_ACK && enConfig) {
esperando = false;
enConfig = false;
configPendiente[nodoActual] = 0;
nodoActual++;
}
}
class Decodificador {
public:
uint32_t errores = 0;
void alimentar(uint8_t b) {
ultimoByte = millis();
switch (estado) {
case ESPERA_SOF: if (b == SOF) estado = ESPERA_LEN; break;
case ESPERA_LEN:
if (b >= 3 && b <= MAX_LEN) { buf[0] = b; n = 1; faltan = b + 2; estado = CUERPO; }
else { errores++; estado = ESPERA_SOF; alimentar(b); }
break;
case CUERPO:
buf[n++] = b;
if (--faltan == 0) {
estado = ESPERA_SOF;
if (crc16(buf, n) == 0) alPaquete(buf[1], buf[2], buf[3], buf + 4, n - 6);
else {
errores++;
uint8_t copia[MAX_LEN + 3];
uint8_t m = n;
memcpy(copia, buf, m);
n = 0;
for (uint8_t i = 0; i < m; i++) alimentar(copia[i]);
}
}
break;
}
}
void vigilarTiempo() {
if (estado != ESPERA_SOF && millis() - ultimoByte > TIEMPO_BYTE_MS) { errores++; estado = ESPERA_SOF; n = 0; }
}
private:
enum Estado { ESPERA_SOF, ESPERA_LEN, CUERPO } estado = ESPERA_SOF;
uint8_t buf[MAX_LEN + 3];
uint8_t n = 0, faltan = 0;
unsigned long ultimoByte = 0;
};
Decodificador decodificador;
void mandarConsulta() {
seq++;
if (configPendiente[nodoActual]) {
uint8_t d[3] = { seq, (uint8_t)(configPendiente[nodoActual] & 0xFF), (uint8_t)(configPendiente[nodoActual] >> 8) };
enConfig = true;
enviar(nodoActual, TIPO_CONFIG, d, sizeof(d));
} else {
enConfig = false;
enviar(nodoActual, TIPO_PETICION, &seq, 1);
}
esperando = true;
venceEn = millis() + ESPERA_MS;
}
void sondear() { // se llama en cada vuelta del loop(): nunca espera
if (nodoActual > NODOS) { // vuelta completa: se espera a que se cumpla el período
if (millis() - inicioVuelta >= PERIODO_MS) { inicioVuelta = millis(); nodoActual = 1; vueltas++; }
return;
}
if (!esperando) { intento = 0; mandarConsulta(); }
else if ((long)(millis() - venceEn) >= 0) {
tiempos++;
if (intento < MAX_REINTENTOS) { intento++; mandarConsulta(); }
else { esperando = false; marcarFallo(nodoActual); nodoActual++; }
}
}
// ---------- Wi-Fi y MQTT: no bloquean el sondeo por mucho tiempo ----------
WiFiClient red;
PubSubClient mqtt(red);
unsigned long proximoIntento = 0, esperaReconexion = 5000;
void alMensaje(char *tema, byte *carga, unsigned int largo) { // planta/nodo03/config/periodo = 30
unsigned nodo = 0;
char valor[8] = { 0 };
if (sscanf(tema, "planta/nodo%2u/config/periodo", &nodo) != 1 || nodo < 1 || nodo > NODOS || largo == 0 || largo >= sizeof(valor)) return;
memcpy(valor, carga, largo);
long p = atol(valor);
if (p >= 1 && p <= 3600) configPendiente[nodo] = (uint16_t)p; // se envía en la próxima vuelta
}
void atenderConexion() {
if (WiFi.status() != WL_CONNECTED) return; // el Wi-Fi se reconecta solo (setAutoReconnect)
if (mqtt.connected()) { mqtt.loop(); return; }
if ((long)(millis() - proximoIntento) < 0) return;
if (mqtt.connect("pasarela", nullptr, nullptr, "planta/pasarela/estado", 1, true, "fuera_de_linea")) { // la última voluntad avisa de una caída
mqtt.publish("planta/pasarela/estado", "en_linea", true);
mqtt.subscribe("planta/+/config/periodo");
esperaReconexion = 5000;
for (uint8_t n = 1; n <= NODOS; n++) { // se vuelve a decir el estado de cada nodo
char t[40];
snprintf(t, sizeof(t), "planta/nodo%02u/estado", n);
mqtt.publish(t, enLinea[n] ? "en_linea" : "fuera_de_linea", true);
}
} else {
proximoIntento = millis() + esperaReconexion; // retroceso exponencial: 5, 10, 20, 40, 60 s
esperaReconexion = min<unsigned long>(esperaReconexion * 2, 60000);
}
}
void publicarUna() {
if (!mqtt.connected() || cuenta == 0) return;
const Entrada &e = cola[cabeza];
char tema[40], carga[96];
if (e.esEstado) {
snprintf(tema, sizeof(tema), "planta/nodo%02u/estado", e.nodo);
if (!mqtt.publish(tema, e.enLinea ? "en_linea" : "fuera_de_linea", true)) return; // si falla, se conserva
} else {
snprintf(tema, sizeof(tema), "planta/nodo%02u/lectura", e.nodo);
snprintf(carga, sizeof(carga), "{\"t\":%.1f,\"h\":%.1f,\"bat\":%u,\"seq\":%u,\"edad\":%lu}", e.t / 100.0, e.h / 100.0, e.bat, e.seq, (millis() - e.tms) / 1000);
if (!mqtt.publish(tema, carga)) return;
}
cabeza = (cabeza + 1) % CAPACIDAD;
cuenta--;
}
void publicarEstadisticas() {
static unsigned long ultima = 0;
if (millis() - ultima < 60000 || !mqtt.connected()) return;
ultima = millis();
char carga[128];
snprintf(carga, sizeof(carga), "{\"vueltas\":%lu,\"respuestas\":%lu,\"tiempos\":%lu,\"crc\":%lu,\"invalidas\":%lu,\"cola\":%u,\"descartadas\":%lu}",
vueltas, respuestas, tiempos, decodificador.errores, invalidas, cuenta, descartadas);
mqtt.publish("planta/pasarela/stats", carga);
}
void setup() {
Serial.begin(115200);
Serial1.begin(BAUDIOS, SERIAL_8N1, 16, 17);
pinMode(PIN_DE, OUTPUT);
digitalWrite(PIN_DE, LOW);
for (uint8_t n = 0; n <= NODOS; n++) { fallos[n] = 0; enLinea[n] = true; configPendiente[n] = 0; }
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_CLAVE);
mqtt.setServer(MQTT_SERVIDOR, MQTT_PUERTO);
mqtt.setCallback(alMensaje);
mqtt.setSocketTimeout(2); // un intento fallido no deja el bus sin atender más de 2 s
inicioVuelta = millis();
}
void loop() {
while (Serial1.available()) decodificador.alimentar(Serial1.read());
decodificador.vigilarTiempo();
sondear();
atenderConexion();
publicarUna();
publicarEstadisticas();
}
- El orden de las comprobaciones en
alPaquetees: destino, que haya una consulta en curso, que el origen sea el nodo consultado y que la secuencia coincida. Una respuesta tardía de un nodo anterior o una repetición no coinciden con la secuencia y se descartan; sin eso, una respuesta vieja se publicaría como lectura nueva. - Una lectura inválida no libera la espera:
esperandosigue en verdadero, vence el temporizador, se reintenta y, tras los reintentos, se cuenta un fallo. Así un sensor averiado no se distingue de un nodo caído desde el estado, pero sí desde el contadorinvalidas. - La cola es un anillo de 64 entradas con
cabezaycuenta.encolardescarta la más vieja si está llena;publicarUnasaca la más vieja solo sipublishtuvo éxito, para no perder una entrada si la conexión falla justo al publicar. - La reconexión es no bloqueante:
atenderConexioncompara conproximoIntentoy duplica la espera tras cada fracaso (5, 10, 20, 40, 60 s).setSocketTimeout(2)acota a 2 s lo que puede demorar un intento; en ese tiempo el bus queda sin atender, pero los nodos conservan su última medición y la consulta se hace después, sin pérdida. - La última voluntad se registra al conectar (
mqtt.connect(..., "planta/pasarela/estado", 1, true, "fuera_de_linea")), y al conseguirlo la puerta de enlace publicaen_lineay repite el estado de cada nodo. - Las credenciales están a la vista y el puerto es 1883 para que el ejemplo corra sin configurar nada. Un proyecto real usa
WiFiClientSecurecon la raíz del servidor, el puerto 8883 y un usuario por dispositivo (tema 49). - Se necesita la biblioteca PubSubClient (Nick O'Leary) y, para Wi-Fi, la del ESP32.
10 · Con las herramientas del tema 48
Verificar con herramientas
Cuatro instrumentos, cada uno con su pregunta:
| Instrumento | Pregunta | Qué se espera |
|---|---|---|
| Analizador lógico en el bus (decodificador de UART a 19 200 8N1, canales sobre RO y DE de la puerta de enlace) | ¿Se cumple el presupuesto de tiempo? | Cada 10 s una ráfaga de 8 pares consulta-respuesta; consulta de 4,2 ms, respuesta de 8,3 ms; separación de unos 4 ms; DE alto exactamente durante la trama del que habla |
| El espía del tema 48 (ESP32 de solo escucha sobre el mismo par) | ¿Qué tramas buenas y malas hay, y de qué nodo? | Líneas [emisor] ... LECTURA y ACK sin tramas malas con el bus limpio; con ruido, CRC incorrecto en los nodos más lejanos |
mosquitto_sub -h IP -v -t 'planta/#' | ¿Qué llega al broker y con qué forma? | Líneas como planta/nodo03/lectura {"t":20.6,"h":50.4,"bat":3300,"seq":58,"edad":0}, una por nodo por vuelta, y los estados retenidos al suscribirse |
mosquitto_pub -h IP -t planta/nodo03/config/periodo -m 30 | ¿Llega el comando al nodo? | En el monitor del nodo 3 «período 30 s» tras una o dos vueltas |
Y los contadores que publica la puerta de enlace cada 60 s en planta/pasarela/stats permiten comprobar la conservación de las cuentas, como en el tema 47: por vuelta deberían ser 8 las respuestas y 0 los tiempos si el bus está limpio; vueltas × 8 = respuestas + (nodos sin respuesta tras los reintentos); la cola debería ser 0 cuando el broker está disponible; y la suma de descartadas es la pérdida de datos real del sistema, la única que debe vigilarse con una alarma.
11 · Analogía con programación
La puerta de enlace en Python
El modelo siguiente reproduce la política de la puerta de enlace sin hardware: el sondeo de los nodos con reintentos y pérdidas, el nodo caído durante un tramo de vueltas, la declaración de «fuera de línea» tras un umbral de vueltas seguidas, la cola con capacidad fija y el corte de la nube. Usa el mismo generador con semilla que el laboratorio, de modo que con los mismos parámetros dan los mismos números. Corre cuatro casos: el de referencia, un búfer mayor, y un enlace malo con dos umbrales distintos.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
import json
class Azar:
"""Generador con semilla (mulberry32): el mismo que usa el laboratorio de la página."""
def __init__(self, semilla):
self.a = semilla & 0xFFFFFFFF
def siguiente(self):
self.a = (self.a + 0x6D2B79F5) & 0xFFFFFFFF
a = self.a
t = ((a ^ (a >> 15)) * (a | 1)) & 0xFFFFFFFF
t = ((t + ((t ^ (t >> 7)) * (t | 61))) & 0xFFFFFFFF) ^ t
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296
RTT_MS, ESPERA_MS = 16.5, 40 # viaje de un nodo a 19 200 baudios y espera de la pasarela
def simular(n, ciclos, periodo, p, reint, muerto, desde, hasta, corte_desde, corte_hasta, buffer, umbral, semilla):
"""Sondeo de n nodos con reintentos, nodos declarados caídos tras 'umbral' vueltas seguidas
sin respuesta y publicación por MQTT con un corte de la nube durante el cual se guarda en un búfer."""
azar = Azar(semilla)
fallos = [0] * (n + 1)
en_linea = [True] * (n + 1)
cola = []
r = dict(generadas=0, fallidas=0, intentos=0, eventos=0, publicadas=0, descartadas=0, max_edad=0, bus_ms=0.0, falsos=0)
def encolar(ciclo):
if len(cola) >= buffer:
cola.pop(0) # lleno: se pierde la más vieja
r["descartadas"] += 1
cola.append(ciclo)
for c in range(1, ciclos + 1):
for nodo in range(1, n + 1):
caido = nodo == muerto and desde <= c <= hasta
ok = False
if caido:
r["intentos"] += reint + 1
r["bus_ms"] += (reint + 1) * ESPERA_MS
else:
for _ in range(reint + 1):
r["intentos"] += 1
if azar.siguiente() < p: # se perdió la consulta
r["bus_ms"] += ESPERA_MS
continue
if azar.siguiente() < p: # se perdió la respuesta
r["bus_ms"] += ESPERA_MS
continue
ok = True
r["bus_ms"] += RTT_MS
break
if ok:
r["generadas"] += 1
fallos[nodo] = 0
encolar(c)
if not en_linea[nodo]: # volvió: se publica el cambio de estado
en_linea[nodo] = True
r["eventos"] += 1
encolar(c)
else:
r["fallidas"] += 1
fallos[nodo] += 1
if fallos[nodo] == umbral and en_linea[nodo]:
en_linea[nodo] = False
r["eventos"] += 1
encolar(c)
if not caido:
r["falsos"] += 1 # estaba sano: fue mala suerte
if not (corte_desde > 0 and corte_desde <= c <= corte_hasta):
for ciclo in cola:
r["publicadas"] += 1
r["max_edad"] = max(r["max_edad"], (c - ciclo) * periodo)
cola.clear()
r["en_cola"] = len(cola)
return r
ejemplo = json.dumps({"t": 23.5, "h": 55.0, "bat": 3300, "seq": 17}, separators=(",", ":"))
print("JSON de una lectura:", ejemplo, f"({len(ejemplo)} bytes)")
casos = (
("base", dict(p=0.05, buffer=40, umbral=3)),
("búfer de 100", dict(p=0.05, buffer=100, umbral=3)),
("30 % de pérdida, 3 vueltas", dict(p=0.30, buffer=40, umbral=3)),
("30 % de pérdida, 8 vueltas", dict(p=0.30, buffer=40, umbral=8)),
)
for nombre, extra in casos:
r = simular(8, 60, 10, extra["p"], 2, 5, 20, 34, 30, 35, extra["buffer"], extra["umbral"], 42)
print(f"{nombre}: {r['generadas']} lecturas, {r['fallidas']} sin respuesta, {r['publicadas']} publicadas,",
f"{r['eventos']} cambios de estado, {r['descartadas']} descartadas, edad máxima {r['max_edad']} s,",
f"{r['falsos']} falsas alarmas, bus {r['bus_ms'] / 60:.0f} ms por vuelta")
Resultado: el JSON de una lectura {"t":23.5,"h":55.0,"bat":3300,"seq":17} ocupa 39 bytes. Con 8 nodos, 60 vueltas (10 minutos), 5 % de pérdida por trama, el nodo 5 caído en las vueltas 20 a 34 y la nube cortada en las 30 a 35: caso base, 465 lecturas con respuesta, 15 sin respuesta (las del nodo caído), 455 publicadas, 2 cambios de estado (el nodo 5 cae y vuelve), 12 descartadas por el búfer de 40, edad máxima de 50 s, ninguna falsa alarma y 192 ms de bus por vuelta. Con un búfer de 100: 467 publicadas (todas), ninguna descartada y edad máxima de 60 s. Con 30 % de pérdida y 3 vueltas de umbral: 407 con respuesta, 73 sin ella, 4 cambios de estado, 1 falsa alarma y 407 ms de bus por vuelta. Con el mismo 30 % y 8 vueltas de umbral: ninguna falsa alarma y solo los 2 cambios del nodo caído (a costa de tardar 80 s, en lugar de 30, en declararlo).
Tres lecciones del modelo. Primera: los reintentos hacen invisible a la pérdida moderada: con 5 % de pérdida por trama, solo las 15 consultas al nodo caído quedan sin respuesta; las 465 restantes salen. Segunda: el búfer define cuánto aguanta el sistema un corte: 40 entradas pierden 12 en un corte de 6 vueltas, y 100 no pierden ninguna; es la cuenta de la herramienta 4. Tercera: el umbral de vueltas es un compromiso: con un enlace muy malo (30 %), 3 vueltas producen una falsa alarma en 10 minutos y 8 no, pero el aviso de una caída real tarda 80 s; un enlace así no se arregla con un umbral, se arregla con el cable.
12 · Exploración interactiva
Laboratorio: bus, tráfico, alarmas, búfer y simulación
El laboratorio tiene cinco herramientas, una por cada cuenta que hay que hacer antes de instalar. La primera calcula el presupuesto de tiempo del bus con el sondeo. La segunda, el tráfico de MQTT según cómo se agrupan los mensajes. La tercera, la probabilidad de falsas alarmas al declarar un nodo fuera de línea. La cuarta dimensiona el búfer de la puerta de enlace para un corte. La quinta simula el ciclo completo, con nodo caído, corte de la nube y pérdidas, con un generador con semilla que coincide con el modelo de Python.
1. Presupuesto de tiempo del bus
Escribí los parámetros del bus.
2. Tráfico de MQTT
Escribí los parámetros.
3. Falsas alarmas de «fuera de línea»
Escribí los parámetros.
4. Búfer de la puerta de enlace
Escribí los parámetros.
5. Simulación del ciclo completo
Escribí los parámetros.
En la herramienta 1, la consulta tiene un byte de datos (la secuencia) y la respuesta tantos como se indiquen; todo a 10 bits por byte. En la 2, el tamaño de un mensaje MQTT es el encabezado fijo, el largo restante, el tema y los datos (QoS 0), y para el cable se suman unos 80 bytes de encabezados TCP/IP y acuse. En la 3, las vueltas fallan de manera independiente. En la 4, el búfer descarta la entrada más vieja al llenarse. En la 5, un intento exitoso cuesta 16,5 ms de bus y uno fallido, 40; el nodo caído no contesta durante sus vueltas y el corte afecta a la publicación, no al bus.
Recorrido guiado
- En la herramienta 1, con los valores por defecto: consulta de 8 bytes (4,17 ms) y respuesta de 16 (8,33 ms), 16,5 ms por nodo, 132 ms por vuelta, 1,32 % del período, 120 ms por cada nodo caído, 960 ms con todos caídos (9,6 %) y capacidad de 303 nodos usando la mitad del período. Con 9600 baudios, 60 nodos y 2 s: 29 ms por nodo, 1,74 s por vuelta, 87 % del período y 7,2 s con todos caídos: no entra. Con 16 nodos a 9600: 29 ms por nodo y una espera de 40 ms que es solo 1,4 veces el viaje (la lectura avisa); con 70 ms: 3,36 s en el peor caso y holgura.
- En la herramienta 2, con 8 nodos cada 10 s: un tema por magnitud son 24 mensajes por vuelta, 6,5 MB al día de MQTT y 23,1 MB en el cable; un JSON por nodo, 8 mensajes de 65 bytes, 4,5 MB y 10 MB; un JSON con todos, un mensaje de 350 bytes, 3,0 MB y 3,7 MB. Con 100 nodos: un JSON por nodo son 125 MB por día y uno solo con todos, 36 MB.
- En la herramienta 3, con 5 % de pérdida, 2 reintentos y 3 vueltas: una vuelta de un nodo sano falla con 9,3 × 10⁻⁴ y tres seguidas con 8,0 × 10⁻¹⁰; en toda la red, una falsa alarma cada 50 años, y una caída real se avisa en 30 s. Con 30 % de pérdida: la vuelta falla 13 % de las veces y hay unas 161 falsas alarmas por día; con 8 vueltas, una cada 151 días y el aviso tarda 80 s. Con 10 % de pérdida: 2 vueltas dan 3,25 falsas alarmas por día y 4 vueltas, una cada 18 años.
- En la herramienta 4, con 5 minutos de corte y 128 lecturas: se generan 240, se guardan 128, se pierden 112 (47 %), el búfer usa 3 KiB y se vacía en 2,6 s; para cubrir el corte hacen falta 240 lecturas (5,6 KiB). Con 60 minutos y 3000 lecturas: 2880 generadas, ninguna perdida, 70 KiB y 58,5 s de vaciado. Con la cola de 64 entradas del proyecto y 3 minutos: se pierden 80 de 144.
- En la herramienta 5, con los valores por defecto: 480 consultas, 465 con respuesta y 15 sin ella (las del nodo caído), 455 publicadas, 2 cambios de estado, 12 descartadas, edad máxima de 50 s, ninguna falsa alarma y 192 ms de bus por vuelta. Con búfer de 100: 467 publicadas, ninguna descartada, edad máxima de 60 s. Con 30 % de pérdida: 407 con respuesta y 1 falsa alarma (4 estados); con 8 vueltas: ninguna. Son los números del modelo de Python.
Desafío: ampliar a 30 nodos y a un corte largo
El cliente quiere llegar a 30 nodos y exige no perder datos en cortes de hasta una hora. Con las herramientas: (1) Bus: a 19 200 baudios, 30 nodos son 30 × 16,5 = 495 ms por vuelta (5 % del período) y, con todos caídos, 30 × 120 = 3,6 s (36 %): entra. Con un período de 10 s sigue habiendo holgura, pero conviene bajar los reintentos de nodos caídos conocidos, que no hace falta esperarlos tres veces (una política habitual: a un nodo declarado caído se lo consulta con un solo intento por vuelta). (2) Falsas alarmas: con 5 % de pérdida y K = 3, 30 nodos dan una falsa alarma cada 13 años; aceptable. (3) Tráfico: 30 nodos con un JSON por nodo: unos 37 MB por día en el cable; sin problema en una red local. (4) Búfer: 30 nodos cada 10 s son 3 lecturas por segundo; una hora son 10 800 entradas, de 24 bytes: 253 KiB. En RAM sería casi todo lo que tiene un ESP32, así que el búfer se pasa a la memoria flash (un archivo circular en LittleFS o la partición de datos), con un cuidado: no escribir lectura por lectura (desgaste, tema 49), sino en bloques de decenas de lecturas, y vaciar a 50 mensajes por segundo (unos 3,8 minutos de vaciado, mientras siguen llegando datos nuevos). (5) Alternativa: agrupar las 30 lecturas de cada vuelta en un solo mensaje reduce el tráfico por 5 y la cantidad de mensajes por 30. (6) Orden de prioridad: antes de ampliar, medir con el analizador y el espía el bus real con los 30 nodos y la terminación; la mayor sorpresa suele estar en el cable. Es el método de decisión del tema 50 aplicado a una ampliación: requisitos con números, cuentas, y el riesgo mayor probado primero.
13 · Seguir mejorando
Ampliaciones y la variante inalámbrica
La arquitectura en capas permite cambiar el medio de los nodos sin tocar la parte de MQTT. La tabla compara el proyecto con su variante inalámbrica, en la que los nodos son sensores a pila que hablan por LoRa:
| Aspecto | Nodos RS-485 (este proyecto) | Nodos LoRa a pila |
|---|---|---|
| Quién inicia | La puerta de enlace sondea; los nodos solo contestan | El nodo envía cuando quiere (despierta, mide, transmite, duerme); la puerta de enlace solo escucha |
| Detectar un nodo caído | No contesta a la consulta tras los reintentos | No llega ningún mensaje en varios períodos (lo que implica un temporizador por nodo en la puerta de enlace) |
| Colisiones | Ninguna: un solo hablante a la vez | Posibles: 8 nodos con 20 bytes en SF9 cada 60 s dan 185 ms de aire cada uno, una carga de 0,025 y unas 4,8 % de colisiones con acceso aleatorio |
| Confirmación | Cada consulta tiene respuesta; el maestro reintenta | Sin confirmación por defecto; con confirmación, una ventana de recepción y más energía; o aceptar la pérdida y mandar la lectura siguiente |
| Configuración remota | Un mensaje CONFIG en la vuelta siguiente | Solo en una ventana de recepción tras un envío del nodo: la orden espera hasta el próximo mensaje |
| Alimentación | Local, sin límite de consumo | A pila: con 7,8 mC por mensaje, 2000 mAh duran 1,3 años si se envía cada 60 s y 6,5 años cada 10 minutos |
| Distancia | Hasta 1200 m por cable | Kilómetros sin cable, con el ciclo de trabajo del 1 % |
| Lo que no cambia | El registro de 9 bytes, la validación de rangos, el estado retenido, los temas MQTT, la cola y la última voluntad: toda la parte superior de la puerta de enlace | |
Otras ampliaciones, en orden de utilidad:
- Seguridad real. TLS en el puerto 8883 con verificación del certificado y un usuario por dispositivo; después, HMAC y contador en las tramas del bus (sección 7).
- Hora. Sincronizar la puerta de enlace con NTP y poner una marca de tiempo UTC en cada lectura, en lugar de (o junto a) la edad. Requiere el reloj para el certificado de TLS.
- Modbus en el campo. Cambiar el protocolo propio por Modbus RTU (tema 26) cuando haya equipos ya instalados que lo hablan. Solo se reemplazan
enviar, el decodificador yalPaquete; la cola y MQTT quedan igual. - Un panel. Node-RED o Grafana con una base de series temporales (InfluxDB) suscrita a
planta/#, con alarmas por nodo fuera de línea y por la última voluntad de la puerta de enlace. - Redundancia. Una segunda puerta de enlace, en espera, que toma el bus si la principal calla (el estado y la última voluntad le permiten saberlo).
- Actualización remota del firmware de los nodos por el bus, con firma (tema 49) y en bloques con CRC, o de la puerta de enlace por Wi-Fi.
- Un modo de diagnóstico que ponga la puerta de enlace en escucha, con el espía del tema 48, para ver el bus sin molestar.
Y las buenas prácticas del proyecto, que resumen el curso: (1) nada espera: máquinas de estados y tiempos de vencimiento en el bucle; (2) nada se confía: cada trama se verifica con el CRC y cada valor, con su rango; (3) nada se pierde sin que se cuente: un contador por cada cosa que puede salir mal, y un informe periódico; (4) toda falla se prueba provocándola; (5) las decisiones se escriben con los números que las sostienen y los hechos que las cambiarían.
14 · Comprensión
Confusiones frecuentes
«Si un nodo no contesta una vez, hay que declararlo caído»
El bus tiene pérdidas: un nodo sano falla algún intento (y algún reintento) de vez en cuando. Declarar la caída tras una vuelta con 30 % de pérdida produce 160 falsas alarmas por día. Se exige que falle un número de vueltas seguidas, con todos los reintentos agotados, y ese número es un compromiso entre falsas alarmas y velocidad de aviso (herramienta 3).
«Si se cae el Wi-Fi, la puerta de enlace tiene que esperar a que vuelva»
No: el bus y la red son independientes. La puerta de enlace sigue sondeando y guarda las lecturas en una cola; al volver el Wi-Fi las publica con su edad. Esperar a la red detendría el sondeo, y los nodos y la planta no tienen por qué enterarse de que se cayó un router.
«El búfer tiene que guardar todo, pase lo que pase»
Tiene capacidad finita. Se dimensiona con una cuenta (lecturas por segundo por duración del corte que se quiere cubrir) y, si se llena, se descarta lo más viejo y se cuenta. Lo que se vigila es el contador de descartadas; si sube, el búfer es chico para la realidad.
«La temperatura debería ser un mensaje retenido, para que cualquiera la vea al conectarse»
Un mensaje retenido es el último valor, sin importar cuánto hace que se publicó. Si el nodo se apagó hace una hora, el tablero que se conecta vería una temperatura de hace una hora como si fuera actual. El estado sí se retiene (es el último hecho conocido) y la temperatura no (o, si se retiene, lleva su edad o su hora).
«Con el CRC bien, la lectura es buena»
El CRC dice que los bytes llegaron como salieron. Un sensor averiado puede mandar 120 °C con un CRC perfecto. La puerta de enlace valida versión y rangos y cuenta las inválidas aparte de los errores de CRC.
«Basta con una respuesta del nodo, no importa de cuál consulta sea»
Importa. Si el maestro pasa al siguiente nodo tras un tiempo de espera y la respuesta atrasada del anterior llega después, sin secuencia se tomaría como la del nodo en curso. La secuencia de la consulta, devuelta en la respuesta, la liga a su pregunta y descarta las tardías.
«El sondeo es lento; mejor que los nodos manden cuando quieran»
Con 8 nodos, una vuelta cuesta 132 ms y el bus está ocupado el 1,3 % del tiempo. El sondeo elimina las colisiones, da una medida inequívoca de qué nodos viven y simplifica el diseño. Los nodos que mandan por su cuenta se justifican cuando no tienen alimentación (a pila, que duermen) o cuando las alarmas no pueden esperar su turno.
«Si funciona con 3 placas en la mesa, ya está»
No se prueban en la mesa las terminaciones, los 300 m de cable, los motores cerca, las 8 placas con cargas reales ni los cortes de red. Las pruebas 4 a 8 del plan provocan esas fallas, y la instalación definitiva se vuelve a medir con el analizador y los contadores.
15 · Práctica guiada
Predecí antes de comprobar
1. Dimensionar el bus
Se quiere llegar a 16 nodos a 9600 baudios, con la misma espera de 40 ms y 2 reintentos. ¿Cuánto cuesta una vuelta? ¿Está bien la espera?
Ver solución
La consulta de 8 bytes dura 8,33 ms y la respuesta de 16 bytes, 16,7 ms; con 4 ms de vuelta, 29 ms por nodo y 464 ms por vuelta de 16. Pero la espera de 40 ms es solo 1,4 veces el viaje: casi no hay margen y cualquier demora hará que la puerta de enlace reintente una lectura que iba a llegar (copias sin pérdidas, tema 47). Con 70 ms (2,4 veces) el peor caso con todos caídos es de 3,36 s, que cabe en 10 s.
2. El tráfico de una flota
Con 100 nodos cada 10 s en una conexión celular con un tope de 1 GB por mes, ¿cuál de los tres formatos de MQTT conviene?
Ver solución
Un JSON por nodo mueve 125 MB al día en el cable: 3,76 GB por mes, que no entra. Un solo JSON con todos los nodos por vuelta: 36,3 MB al día, 1,09 GB por mes, apenas por encima del tope; se ajusta con un período de 15 o 20 s, o con JSON más compacto (menos bytes por nodo) o con un formato binario como CBOR (tema 46). Un tema por magnitud sería el peor. Es la misma lección del tema 50: agrupar es la mejor optimización.
3. Elegir el umbral
Con 10 % de pérdida por trama, 2 reintentos, 8 nodos y un período de 10 s, y un requisito de avisar una caída real en menos de un minuto, ¿qué umbral de vueltas elegís?
Ver solución
Una vuelta de un nodo sano falla con (1 − 0,9²)³ = 0,0069. Con K = 2: 4,7 × 10⁻⁵ por nodo y por vuelta, 3,25 falsas alarmas por día en toda la red: demasiadas. Con K = 4: 2,2 × 10⁻⁹, una falsa alarma cada 18 años, y se avisa en 40 s, menos de un minuto. K = 5 también cumple (50 s). K = 4 es un buen equilibrio.
4. El búfer para un corte largo
Una caída del enlace puede durar 20 minutos. Con 8 nodos cada 10 s y entradas de 24 bytes, ¿qué capacidad hace falta para no perder nada, cuánta memoria es y cuánto tarda en vaciarse a 50 mensajes por segundo?
Ver solución
Se generan 0,8 lecturas por segundo: 960 en 20 minutos. Con la cola de 64 entradas se pierden 896 (93 %). Para no perder nada hacen falta 960 entradas, de 24 bytes: 22,5 KiB, que caben en la RAM. El vaciado dura 960 / (50 − 0,8) ≈ 19,5 s.
5. Un diagnóstico con los contadores
El monitor del nodo 4 informa 180 consultas válidas y 0 errores de CRC, igual al número de vueltas. Pero la puerta de enlace cuenta cero respuestas de ese nodo y crc en cero. ¿Qué pasa?
Ver solución
Las consultas llegan bien (el nodo las cuenta todas), pero las respuestas no llegan ni dañadas (el CRC de la puerta de enlace no sube): el nodo contesta y no se oye nada, de modo que el transmisor del nodo no está habilitado o no está conectado al bus. Se mira con el analizador: ¿sube DE cuando el nodo contesta? (el pin de DE mal cableado, o configurado en otro GPIO); ¿hay actividad en el par A–B? (A y B del nodo al revés o cable cortado de ese lado). Los otros nodos funcionan, y eso descarta la puerta de enlace y el bus en general.
6. Qué retener
De los cinco temas de MQTT de la puerta de enlace (lectura, estado de nodo, estado de la pasarela, estadísticas y comando), ¿cuáles se retienen y por qué?
Ver solución
Se retienen los dos estados (el de cada nodo y el de la puerta de enlace): son el último hecho conocido, y un tablero que se conecta tarde los necesita de inmediato. No se retienen las lecturas (un dato viejo se vería como actual), las estadísticas (se renuevan cada minuto) ni el comando (una orden retenida se volvería a ejecutar con cada nueva suscripción de la puerta de enlace, y no debe).
7. El mismo proyecto con LoRa
Si los 8 nodos fueran sensores a pila con LoRa SF9 enviando 20 bytes cada 60 s, ¿qué probabilidad hay de que un mensaje choque con otro? ¿Cuánto dura una batería de 2000 mAh con 7,8 mC por mensaje y 15 µA de sueño?
Ver solución
20 bytes en SF9 son 185 ms de aire. La carga del canal es G = 8 × 0,185 / 60 = 0,0247 y, con acceso aleatorio, la probabilidad de choque es 1 − e−2G ≈ 4,8 %. La corriente media es 15 + 7,83 / 60 × 1000 = 145,5 µA, y 2000 mAh al 80 % duran 1,26 años; si se envía cada 10 minutos, 6,5 años. En una lectura que no puede perderse, se agrega una confirmación a nivel de aplicación o se manda cada lectura dos veces.
8. El orden de la seguridad
Con un presupuesto para hacer solo dos de las cuatro mejoras de seguridad de la sección 7 (TLS al broker, HMAC en el bus, permisos de la orden de configuración, protección física de la placa), ¿cuáles hacés primero y por qué?
Ver solución
Primero, TLS y credenciales hacia el broker: la red de la planta (un Wi-Fi, una computadora) está mucho más expuesta que un cable dentro de la planta, y por ahí pasan las lecturas y la contraseña. Segundo, los permisos de la orden de configuración en el broker (solo la puerta de enlace y la administración): es la acción más dañina que se puede disparar a distancia, y se resuelve con una regla en el broker, sin cambiar los nodos. El HMAC en el bus y la protección física se hacen después, según el riesgo de que alguien tenga acceso al cable o a una placa.
16 · Síntesis
Ideas para recordar
- El proyecto integra el curso: un bus RS-485 de 300 m con ocho nodos, un protocolo propio con trama, CRC-16, secuencia, tiempos de espera y reintentos, una puerta de enlace ESP32 que sondea el bus y publica por Wi-Fi en MQTT, con estado retenido y última voluntad. Cada decisión se justifica con los números del tema 50.
- El sondeo maestro-esclavo evita colisiones y permite saber qué nodos viven: a 19 200 baudios, 16,5 ms por nodo, 132 ms por vuelta de 8 (1,3 % del período), 120 ms más por cada nodo caído y 960 ms con todos caídos. La espera debe ser unas 2,4 veces el viaje.
- La puerta de enlace nunca espera: el decodificador arma las tramas,
alPaqueteacepta solo la respuesta esperada (con la secuencia de la consulta) y la valida por rango,sondear()es una máquina de estados con reintentos, una cola circular separa el bus de MQTT y la conexión se repara con retroceso exponencial y un bloqueo acotado. - Un nodo se declara fuera de línea tras K vueltas seguidas sin respuesta, no tras una: con 5 % de pérdida, 2 reintentos y K = 3, una falsa alarma cada 50 años; con 30 % de pérdida, 161 por día, y entonces el problema es el enlace, no el umbral.
- Durante un corte del Wi-Fi o del broker, el sondeo sigue y las lecturas se guardan en un búfer con su edad; al llenarse, se descarta la más vieja y se cuenta. El tamaño sale de una cuenta (lecturas por segundo por duración del corte): 240 lecturas para 5 minutos con 8 nodos.
- En MQTT, un tema por nodo, JSON con la edad, estado retenido y última voluntad (la defensa contra la falla silenciosa de la propia puerta de enlace). Agrupar mensajes reduce el tráfico de 10 a 3,7 MB por día con 8 nodos, a costa de los temas y permisos por nodo. Un comando de configuración llega por un tema, se valida, queda pendiente y se envía al nodo con confirmación; es idempotente.
- Cada falla deja un rastro en los contadores (
tiempos,crc,invalidas,cola,descartadasy los del nodo), y comparando los de los dos extremos se sabe si falla la ida, la vuelta, el nodo o la red. El plan de pruebas provoca cada falla: nodo desconectado, ruido, cortes de red de uno y de tres minutos, reinicio del broker, valores imposibles y comandos. - Como está, es funcional pero no seguro: faltan TLS y permisos hacia el broker, HMAC y contador en las tramas del bus y protección de las claves. Con el medio de los nodos en una capa separada, se puede pasar a Modbus o a nodos inalámbricos (con distintas cuentas de colisiones, confirmación y batería) sin tocar la parte de MQTT.
Para cerrar este tema
Antes de seguir, mirá tu propio diseño: elegí un sistema real o imaginario de al menos diez dispositivos y escribí sus requisitos con números, qué tecnología elegirías y por qué (tema 50), el presupuesto de tiempo del bus o del enlace, qué pasa cuando cae cada pieza, qué contador mostraría cada falla, y qué mejora de seguridad harías primero. Si podés responderlo, estás en condiciones de construir una red de dispositivos que se comunican.
En el último tema, el cierre y puente a los sistemas embebidos, se repasa todo el recorrido (desde los bits de una UART hasta la publicación en la nube), se resumen las buenas prácticas del curso y se indica por dónde seguir: sistemas operativos de tiempo real, el manejo de la energía y el diseño de productos.