// Expediente de evidencia
LoRa Monitor 3DFAB — LoRa Helios
Los sensores LoRaWAN envían datos en formatos distintos. LoRa Helios los reúne en un panel para consultar el estado de los nodos, comparar históricos y exportar mediciones sin revisar cada dispositivo por separado.
- Para quién
Operador y usuarios de una red de sensores: consultar qué se recibió, cuándo y desde qué nodo.
- Mi responsabilidad
Desarrollo full stack: integración TTN, API, persistencia, dashboard y configuración de despliegue.
- Estado actual
Sistema principal en producción activa, desplegado a finales de febrero de 2026. Telemetría con sensores reales en terreno; extensión GPS móvil en fase experimental y no operativa en producción.
- Qué permite hacer
Recepción, históricos, comparación entre nodos y exportación CSV reunidos en un mismo flujo de trabajo.
Contexto
El proyecto atiende la necesidad de una empresa de prototipado y fabricación (3DFAB) de consultar mediciones de sensores LoRaWAN desplegados en distintas ubicaciones. Las cargas útiles varían según el fabricante del nodo y los datos pueden llegar duplicados cuando coinciden gateways en cobertura.
El sistema debía recibir esas transmisiones, unificar la lectura y ofrecer un panel web con permisos por usuario. La operación principal usa sensores ambientales fijos. El proyecto incluye además una línea de trabajo con un dispositivo GPS móvil sobre ESP32, que permanece en fase experimental y no está operativa.
Problema
Los sensores LoRaWAN envían datos en formatos distintos según el fabricante o el firmware: nombres de variables abreviados, alias en español o en inglés, o estructuras anidadas. Además, la red puede entregar un mismo paquete más de una vez si varios gateways lo reciben y lo transmiten hacia The Things Network (TTN).
Sin una plataforma unificada, el operador debía revisar las mediciones en consolas dispersas, sin histórico accesible para los usuarios finales y sin un criterio común para saber si un dispositivo seguía transmitiendo o había dejado de reportar.
Mi rol
Diseño y desarrollo del backend de ingesta (Express, validación con Joi, webhook autenticado), la integración con TTN, el esquema de datos en MongoDB con índices únicos y TTL, la capa de caché en Redis y el panel web en React (Vite, CSS propio, Recharts). Preparé también los Dockerfiles y la configuración Docker Compose para el despliegue.
Restricciones
La documentación de diseño contempla transmisiones típicas cada 10 a 30 minutos; esa frecuencia depende de los nodos y no fue medida aquí. La ingesta debía funcionar aunque el panel estuviera cerrado, autenticar el webhook de TTN y separar el acceso del administrador del de clientes asignados a nodos. El despliegue requería contenedores Docker. Radio, TTN, red, API y MongoDB siguen siendo dependencias de entrega: el sistema no incorpora una cola durable ni garantiza recuperar lecturas perdidas durante un reinicio.
Solución
El operador entra al panel y localiza sus dispositivos mediante búsqueda y filtros. Comprueba la última lectura y abre el nodo que necesita investigar. Desde ahí selecciona variables y un intervalo, examina los gráficos y exporta el histórico a CSV. Si necesita comparar comportamientos, utiliza la vista de comparación de 2 a 10 dispositivos. La exportación permite seleccionar fechas y variable; no depende del límite rápido de lecturas mostrado en el gráfico.
La administración permite gestionar usuarios y asignar nodos, mientras el cliente consulta los que tiene autorizados. Las tarjetas se adaptan a los campos presentes. El soporte de coordenadas y velocidad existe en el software, pero la operación del dispositivo GPS móvil sigue siendo experimental.
Arquitectura
El recorrido de escritura es: nodo LoRaWAN → gateway → TTN → webhook autenticado → API Express → MongoDB. TTN entrega el payload ya decodificado; el backend valida su estructura, normaliza los campos y conserva la medición. La ingesta funciona independientemente de que un usuario tenga abierto el dashboard.
React y Vite forman la interfaz; Recharts presenta los históricos. El resumen del dashboard lee a través de la API mediante polling cada 30 segundos; el detalle histórico se recarga al cambiar filtros o solicitar una actualización. Redis acelera el resumen de dispositivos, y MongoDB mantiene los datos principales. Redis también recibe eventos Pub/Sub internos, pero no hay un consumidor que los convierta en actualizaciones push hacia el navegador.
Leer el diagrama y sus límites
Los nodos ambientales fijos emiten telemetría por radiofrecuencia (915 MHz). El gateway la reenvía por red IP a The Things Network (TTN), donde un decodificador genera un objeto JSON. TTN dispara un webhook HTTP autenticado con timingSafeEqual hacia el servidor Express, que valida el esquema con Joi, normaliza hasta 11 variables ambientales, persiste la lectura en MongoDB con TTL de 90 días e invalida la caché de resumen en Redis. El operador consulta el panel React en Vite, que actualiza métricas mediante polling HTTP cada 30 segundos contra la API Express sin conexión directa a TTN ni a las bases de datos.
El diagrama refleja la arquitectura de telemetría ambiental fija comprobada localmente con dobles de red y una carga sintética de 1.797.705 registros. La rama de rastreo móvil por GPS no se incluye en el flujo operativo por presentar inestabilidad de señal y no contar con validación en campo. El sistema no dispone de cola de mensajes intermedia (MQTT broker/Kafka) para retención persistente durante caídas del servidor API.
Mapa de implementación y evidencia técnica. Pruebas unitarias y de normalización ejecutadas localmente; la disponibilidad de gateway y el backend dependen de la infraestructura de red del despliegue.
Canalización de datos
1. TTN entrega un evento con identificación del dispositivo y decoded_payload. La API comprueba la credencial del webhook, limita solicitudes y valida la envoltura con Joi.
2. La normalización resuelve alias para hasta 11 variables disponibles: partículas, temperatura, humedad, batería, coordenadas, velocidad y calidad de radio. Los metadatos RF se toman del primer gateway informado, con alternativas en el payload. El modelo conserva también la entrada original para diagnóstico.
3. Se guarda la medición, se crea o actualiza el registro del dispositivo y se intenta invalidar la caché del resumen. La respuesta de éxito llega después de ese procesamiento; no se utiliza una cola que confirme antes de persistir. Las fechas locales reconstruidas reciben sufijo UTC: esa convención debe coincidir con la del firmware.
Concurrencia y pérdida de paquetes
Un índice único de MongoDB sobre deviceId y receivedAt impide guardar dos mediciones con la misma clave, incluso cuando compiten inserciones. La aplicación reconoce el error de clave duplicada y devuelve una respuesta de éxito al reenvío. La prueba adicional envió 20 solicitudes idénticas simultáneamente: se conservaron una medición y un dispositivo, con 19 respuestas de duplicado. Es una protección sobre esa identidad de medición, no una recuperación de paquetes perdidos por radio.
El timestamp debe permanecer estable entre reenvíos. Si falta, el normalizador puede asignar la hora de recepción y producir claves diferentes. Además, guardar la medición y actualizar el dispositivo son operaciones separadas: no existe una transacción que abarque todo el flujo. Una inyección de fallo reprodujo una medición guardada con HTTP 500 y sin el registro del dispositivo.
Cuando el resumen no obtiene una entrada de Redis, consulta MongoDB. Este fallback preserva la ruta de lectura sin convertir la caché en requisito de almacenamiento. La ingesta no implementa cola durable ni reintentos propios; su capacidad y recuperación ante fallos deben evaluarse por escenario.
Decisiones
MongoDB frente a PostgreSQL: elegí documentos con campos opcionales e índices por nodo y fecha para integrar diferentes decoders. PostgreSQL con JSONB también era una alternativa válida; la decisión priorizó el encaje con Mongoose y el modelo del proyecto. La implementación usa una colección convencional indexada, con TTL, no una colección time-series nativa.
Polling frente a WebSockets: una consulta cada 30 segundos encaja con transmisiones poco frecuentes y simplifica conexiones y proxies. El compromiso es que la pantalla no recibe cada evento al instante.
Caché auxiliar: Redis reduce consultas repetidas del resumen, pero el histórico permanece en MongoDB. Se acepta que una lectura sin caché tenga otro coste.
Bearer frente a HMAC: la integración utiliza la credencial soportada por TTN y comparación timing-safe. Es autenticación de la llamada; no se presenta como una firma individual del contenido. Estas decisiones están documentadas en tres ADRs.
Seguridad
El acceso de usuarios se apoya en JWT y autorización por rol y asignación de dispositivos. El propósito es que un cliente consulte los nodos que le corresponden y que la gestión de usuarios requiera permisos de administrador.
El webhook tiene autenticación propia, comparación timing-safe, validación estructural con Joi y límite de solicitudes. Los registros de petición apoyan el diagnóstico, pero las pruebas disponibles no acreditan una trazabilidad integral de todos los eventos. La validación de la envoltura tampoco verifica físicamente cada medida del sensor.
La configuración de claves, TLS, proxy, orígenes y exposición de servicios forma parte del despliegue. Las pruebas de esta ficha son evidencia funcional acotada, no una certificación ni una auditoría de seguridad completa.
Pruebas
El 03/10/2026 se reejecutó la suite original sobre el código local revisado, con Node.js 22.23.2, Jest, Supertest y MongoDB temporal: 98/98 casos en 7 archivos. La cobertura de líneas fue 79,45 % y las cuatro métricas superaron el umbral configurado del 70 %. El frontend compiló correctamente; Vite advirtió que el paquete JavaScript supera 500 kB. Estas comprobaciones no probaron TTN, sensores ni producción.
Las 10 comprobaciones adicionales de concurrencia, normalización, lectura sin caché y fallo parcial pertenecen a la revisión aislada del 19/09/2026; no se reejecutaron el 03/10. Ese día sí se ejecutaron los cinco casos del ejemplo público. La suite original no acredita la persistencia de todos los eventos de auditoría. Las pruebas de Redis simulan el cliente y no representan la caída de un servidor Redis real. La tabla siguiente separa fechas y alcance.
| Comprobación | Resultado | Alcance |
|---|---|---|
| Suite original | 98/98 casos · 7 archivos | Reejecutada el 03/10/2026 con Jest, Supertest y MongoDB temporal. |
| Cobertura original | 79,45 % líneas | Statements 78,88 % · ramas 73,76 % · funciones 87,27 %. No incluye todo el frontend ni cada fallo posible. |
| Comprobaciones adicionales | 10/10 casos | Revisión del 19/09/2026: módulos reales, datos sintéticos e inyección de fallos en copia aislada; no reejecutada el 03/10. |
| Duplicados simultáneos | 20 solicitudes → 1 medición | Promise.all, índice único creado: 20 HTTP 200, 19 respuestas de duplicado y 1 dispositivo. |
| Mediciones diferentes | 20 solicitudes → 20 mediciones | Fechas distintas y envíos simultáneos. Comprueba ese escenario, no capacidad máxima. |
| Lectura sin caché | Respuesta desde MongoDB | Ruta y helper reales; operaciones del cliente Redis simuladas con errores. |
| Fallo parcial | Límite reproducido | Fallo al actualizar el dispositivo: HTTP 500 con medición guardada y sin dispositivo. |
Despliegue
La configuración Docker Compose separa el backend Express, el frontend compilado servido por Nginx y Redis. Define reinicio de servicios y health checks. La documentación describe el despliegue con Coolify y MongoDB Atlas como almacenamiento externo.
Preparé esa configuración y el recorrido de puesta en marcha. El sistema principal se encuentra en producción activa. La revisión técnica ejecutó verificaciones locales sin acceder al servidor de la aplicación; los health checks definidos no se presentan como historial de disponibilidad.
Resultado
El resultado es un flujo compartido para revisar la recepción y explorar las mediciones: última lectura, históricos por dispositivo y fecha, comparación entre nodos y exportación CSV. El sistema opera con sensores reales en terreno por el operador y sus usuarios con el despliegue principal activo.
La mejora funcional es trabajar con una representación común de los datos y sus permisos desde una misma interfaz. No se atribuye un porcentaje de ahorro de tiempo ni una disponibilidad medida. La extensión GPS móvil queda identificada como trabajo experimental aparte.
Evidencia
La ficha combina código local revisado, tres decisiones de arquitectura documentadas, pruebas locales y operación real en terreno. El 19/09/2026 se ejecutó una revisión aislada con 10 comprobaciones adicionales; el 03/10/2026 se reejecutaron los 98 casos originales, se compiló el frontend y se verificó la muestra pública.
Las pruebas usan datos sintéticos y MongoDB temporal; no accedieron a bases operativas ni al servidor de la aplicación. El sistema principal opera en producción, manteniendo la verificación técnica local acotada a pruebas unitarias y de integración.
El video muestra la interfaz y la sección final presenta el ejemplo de normalización con cinco casos. No se publican datos reales de sensores ni detalles de acceso a la infraestructura.
Observaciones de campo
Campo y uso: el sistema opera con sensores reales en terreno por el operador y sus usuarios, manteniendo el despliegue principal activo. El recorrido con ESP32 portátil fue un ensayo adicional del equipo, sin mediciones publicadas de alcance, autonomía o pérdida de paquetes.
Pruebas de carga
Carga y capacidad: las comprobaciones simultáneas utilizan 20 solicitudes y datos sintéticos en un servidor local. Evalúan integridad para esos escenarios; no miden el enlace LoRa, latencia productiva, carga sostenida ni capacidad máxima. Las antiguas cifras de 10.000 eventos y latencias permanecen fuera de los resultados acreditados.
Calidad de software y UX
La interfaz prioriza localizar el dato y entender su antigüedad. El indicador de actividad se basa en una última lectura de menos de 30 minutos: expresa recencia de datos, no una prueba directa de conectividad del sensor. Búsqueda, filtros y vistas de detalle reducen el recorrido hasta un nodo concreto.
Los gráficos permiten ajustar intervalos y explorar distintas escalas; la comparación reúne series de varios dispositivos en un eje temporal común sin corregir físicamente sus relojes. La exportación CSV conserva una salida útil para análisis fuera del panel.
En la normalización y calidad de datos, resolver alias con el operador ?? conserva valores válidos como cero. Distinguirlos de una variable ausente evita que la interfaz esconda una lectura real. El ejemplo siguiente aísla y verifica esa decisión.
Muestra de código y prueba de ingeniería
Adaptación pública y autocontenida del criterio de normalización: tres variables ambientales, alias equivalentes, prioridad del nombre canónico y null para un dato ausente. Se excluyen autenticación, persistencia y datos privados.
La prueba ejecuta cinco entradas y comprueba también que no se modifica el payload original. Demuestra este contrato de normalización; la concurrencia del backend se valida por separado en el apartado de pruebas.
// Adaptación aislada: solo campos de sensores ya decodificados. |
function normalizeSensorPayload(rawPayload) { |
const data = rawPayload.uplink_message?.decoded_payload ?? {}; |
return { |
temperature: data.temperature ?? data.temp ?? data.t ?? data.temperatura ?? null, |
humidity: data.humidity ?? data.hum ?? data.h ?? data.humedad ?? null, |
battery: data.battery ?? data.batt ?? data.v_bat ?? null, |
}; |
} |
Prueba de la muestra aislada
import assert from 'node:assert/strict'; |
// Ejecutar junto con la muestra usando Node.js. |
const cases = [ |
[{ temperature: 0, humidity: 0, battery: 0 }, |
{ temperature: 0, humidity: 0, battery: 0 }], |
[{ temp: 21, hum: 45, batt: 3.7 }, |
{ temperature: 21, humidity: 45, battery: 3.7 }], |
[{ temperatura: 18, humedad: 60, v_bat: 3.5 }, |
{ temperature: 18, humidity: 60, battery: 3.5 }], |
[{ temperature: 0, temp: 99, humidity: null, hum: 40 }, |
{ temperature: 0, humidity: 40, battery: null }], |
[{}, { temperature: null, humidity: null, battery: null }], |
]; |
|
for (const [input, expected] of cases) { |
const raw = { uplink_message: { decoded_payload: input } }; |
const before = JSON.stringify(raw); |
assert.deepEqual(normalizeSensorPayload(raw), expected); |
assert.equal(JSON.stringify(raw), before); |
} |
// 5 casos: ceros, alias, precedencia, campos ausentes y datos de entrada intactos. |
5 casos ejecutados: valores cero, alias abreviados, alias en español, prioridad y ausencia de campos; entrada original conservada en todos ellos.