Sistema de domótica (smart home) IoT multi-protocolo. Permite controlar dispositivos físicos (cerraduras, luces, termostatos, sensores) desde una app web, con los dispositivos conectados a gateways físicos en hogares de usuarios.

Arquitectura del Sistema IoT

Componentes

SecciónDescripción
Visión generalDiagrama de las cuatro capas y sus tecnologías
Flujo de un comandoLos 16 pasos desde click en la UI hasta el dispositivo físico
Patrón hook request/responseCómo se convierte MQTT fire-and-forget en API síncrona
AWS IoT Core como busTópicos MQTT, autenticación dual mTLS/SigV4
Persistencia por capaPostgreSQL, DynamoDB, S3/Athena, SQLite, EDN
Provisioning JITRRegistro automático de un gateway nuevo sin intervención humana
SSH TunnelingAcceso remoto sin port-forwarding ni IP pública
Multi-protocolo en gatewayZ-Wave (UDP binario), Zigbee (CLI), Matter (WebSocket JSON-RPC)
SeguridadJWT, BCrypt, mTLS, SigV4, SSH hardening

Decisiones de diseño

PreguntaRespuesta corta
¿Por qué MQTT y no HTTP directo?Los gateways están detrás de NAT, MQTT invierte la conexión
¿Por qué dos versiones del gateway?El proyecto empezó en Clojure; la versión Java agrega Matter y testing
¿Por qué SQLite en el gateway?Embebido, sin proceso separado, ACID, adecuado para single-user
¿Por qué DynamoDB + S3 y no PostgreSQL?VPS de 415 MB — TTL automático en Dynamo, S3 barato para histórico
¿Por qué no Kafka?Recursos insuficientes; MQTT → DynamoDB directo es suficiente
¿Por qué React Query y no Redux?El estado principal es server state; Redux hubiera sido boilerplate puro

Visión General

Sistema de domótica (smart home) IoT multi-protocolo. Permite controlar dispositivos físicos (cerraduras, luces, termostatos, sensores) desde una app web, con los dispositivos conectados a gateways físicos en hogares de usuarios.

Cuatro capas, cada una en su propio proceso/repo:

┌─────────────────────────────────────────────────────────────────────┐
│  CAPA 1 — Presentación                                              │
│  React SPA (Vite + MUI + React Query)                               │
│  Dashboard web, control de dispositivos, gestión de tunnels         │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ HTTPS REST
┌──────────────────────────▼──────────────────────────────────────────┐
│  CAPA 2 — Backend Cloud                                             │
│  Spring Boot (Java) + Clojure Proxy                                 │
│  Auth, multi-tenancy, telemetría, tunnels SSH, analytics            │
└───────┬──────────────────────────────────────────┬──────────────────┘
        │ MQTT5 (AWS IoT Core)                     │ DynamoDB / S3 / Athena
┌───────▼──────────────────────────────────────────▼──────────────────┐
│  CAPA 3 — Gateway (Edge)                                            │
│  Spring Boot (Java) + Clojure FW                                    │
│  Orquestación multi-protocolo, device state, event forwarding       │
└───────┬──────────────────┬──────────────────┬───────────────────────┘
        │ UDP/IPv6         │ subprocess CLI   │ WebSocket JSON-RPC
┌───────▼───────┐  ┌───────▼───────┐  ┌───────▼───────────────────────┐
│  Z-Wave       │  │  Zigbee       │  │  Matter                       │
│  (zipgateway) │  │  (Z3Gateway)  │  │  (python-matter-server)       │
└───────────────┘  └───────────────┘  └───────────────────────────────┘

Flujo Principal de un Comando

Los 16 pasos desde un click en la UI hasta el dispositivo físico:

1.  Usuario hace click en "Lock" en la app web
2.  React Query dispara useMutation → POST /api/v1/{gwId}/proxy/{deviceId}/lock
3.  cloud-side recibe la request HTTP (JWT validado por Spring Security)
4.  cloud-side verifica ownership del gateway en PostgreSQL
5.  cloud-side publica en MQTT: iot/v1/{gwId}/request/{correlationId}
6.  AWS IoT Core enruta el mensaje al gateway suscripto
7.  gateway-side recibe el mensaje MQTT → GatewayApiService lo rutea
8.  GatewayApiService identifica protocolo del device → ZWaveController
9.  ZWaveInterface codifica el frame binario y lo envía por UDP IPv6
10. El lock Z-Wave ejecuta y responde con un frame UDP
11. ZWaveReportHandler procesa la respuesta → actualiza SQLite
12. gateway-side publica evento: iot/v1/{gwId}/response/{correlationId}
13. cloud-side recibe el mensaje, completa el CompletableFuture/channel pendiente
14. La response HTTP viaja de vuelta al frontend
15. React Query invalida el cache del device → re-fetch del estado
16. UI actualiza el ícono del lock

Latencia total estimada: 1–3 segundos (dominado por el round-trip MQTT y la ejecución del dispositivo físico).


AWS IoT Core como Bus de Mensajes Central

El backbone de comunicación del sistema. Toda la comunicación cloud↔gateway pasa por aquí.

Tópicos de comando (cloud → gateway):
  iot/v1/{gwId}/request/{correlationId}    ← comandos REST proxeados

Tópicos de respuesta (gateway → cloud):
  iot/v1/{gwId}/response/{correlationId}   ← respuesta al comando
  iot/v1/{gwId}/event/{timestamp}          ← eventos no solicitados (door open, battery, etc.)
  iot/v1/{gwId}/status                     ← telemetría periódica (~1 msg/min)

Tópicos de provisioning (bootstrap):
  $aws/certificates/create/json            ← solicitar certificado nuevo
  $aws/provisioning-templates/.../provision/json  ← registrar gateway como "thing"

Tópicos de tunneling:
  $aws/things/{name}/tunnels/notify        ← notificación para iniciar tunnel SSH

Autenticación dual:

  • Gateway → AWS IoT: mTLS con certificados X.509 por dispositivo
  • Cloud → AWS IoT: SigV4 con credenciales IAM

Patrón Hook / Request-Response sobre Pub/Sub

El patrón más importante del sistema. Resuelve el problema fundamental: MQTT es fire-and-forget pero la API HTTP necesita respuestas síncronas.

┌─────────────┐   1. registrar hook    ┌──────────────────────┐
│  HTTP Layer │ ─────────────────────► │  Hook Registry       │
│  (request)  │                        │  { corrId → future } │
└──────┬──────┘                        └──────────────────────┘
       │ 2. publish MQTT command                ▲
       ▼                                        │ 4. match + complete future
┌─────────────┐   3. async             ┌────────┴─────────────┐
│  MQTT Broker│ ─────────────────────► │  MQTT Listener       │
│  (AWS IoT)  │   response arrives     │  (background thread) │
└─────────────┘                        └──────────────────────┘

       │ 5. future.get() / alt!! con timeout

┌─────────────┐
│  HTTP Layer │  → 200 OK con respuesta del dispositivo
│  (response) │  → 504 Gateway Timeout si no llega
└─────────────┘

Implementado de forma independiente en cuatro lugares:

ProyectoTecnologíaTimeout
Clojure proxycore.async/alt!! + channel10s
Clojure gatewaycore.async/alt!! + channel30s
Java cloud-sideCompletableFuture.orTimeout()30s
Java gateway-sideCompletableFuture.orTimeout()10s

El matching en el proxy Clojure usa clojure.data/diff para verificar que el filtro es subconjunto del mensaje. Los proyectos Java usan predicados funcionales sobre el mapa del mensaje.


Estrategia de Persistencia por Capa

Cada capa usa el storage más apropiado para sus necesidades:

┌──────────────────────────────────────────────────────────────────────┐
│  CLOUD-SIDE                                                          │
│                                                                      │
│  PostgreSQL ──── usuarios, gateways, tunnels (relacional, ACID)     │
│  DynamoDB ─────── telemetría hot (últimas 48h, TTL automático)      │
│  S3 + Parquet ─── telemetría cold (histórico, columnar, comprimido) │
│  Athena ────────── SQL sobre S3 (query on demand, sin servidor)     │
└──────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│  GATEWAY-SIDE                                                        │
│                                                                      │
│  SQLite ────────── device state, atributos, pincodes (embebido)     │
│  EDN (archivo) ─── device database en versión Clojure               │
│  /tmp/priv.pem ─── private key AWS IoT (0400, ephemeral)            │
└──────────────────────────────────────────────────────────────────────┘

Two-tier Telemetry: El cloud-side rutea transparentemente queries de telemetría según el rango de fechas:

  • < 48h → DynamoDB (fast, indexed)
  • > 48h → Athena sobre S3 Parquet (lento pero barato)
  • Cruza el límite → ambos en paralelo con CompletableFuture.thenCombine()

El ArchiveService corre diariamente a las 2AM UTC y mueve el día anterior de DynamoDB a S3 en formato Parquet con compresión SNAPPY.


Provisioning de Gateways (JITR)

El proceso por el que un gateway nuevo obtiene su identidad. Completamente automático, sin intervención humana en campo:

┌─────────────────────────────────────────────────────────────┐
│  GATEWAY (en caja, sin config)                              │
│                                                             │
│  1. Conecta con certificado de bootstrapping (ephemeral)    │
│     └─► AWS IoT: $aws/certificates/create/json             │
│                                                             │
│  2. AWS responde con certificado permanente                 │
│     └─► Gateway lo guarda a disco (provisioned.creds)      │
│                                                             │
│  3. Reconecta con certificado permanente (mTLS)             │
│     └─► AWS IoT: $aws/provisioning-templates/.../provision │
│                                                             │
│  4. AWS registra el gateway como "thing"                    │
│     └─► Gateway queda registrado, listo para recibir cmds  │
└─────────────────────────────────────────────────────────────┘

SSH Tunneling para Acceso Remoto

Los gateways están en redes privadas (NAT del hogar). Para acceso remoto sin tocar el router:

┌──────────────┐         ┌───────────────────────┐         ┌─────────────┐
│  Técnico     │  SSH    │  Servidor Cloud        │  MQTT   │  Gateway    │
│  (exterior)  │────────►│  (Lightsail/EC2)       │◄────────│  (en casa)  │
└──────────────┘  :9123  │                        │  notify │             │
                         │  1. Recibe notificación│         │  5. Inicia  │
                         │  2. Crea usuario iot-X │         │     tunnel  │
                         │  3. Escribe auth_keys  │         │     reverso │
                         │  4. Abre puerto en FW  │         │             │
                         └───────────────────────┘         └─────────────┘

authorized_keys:
  no-pty,no-X11-forwarding,permitlisten="0.0.0.0:9123",
  command="/bin/echo",ssh-rsa [KEY]

Tres capas de seguridad:
  1. Firewall: puerto abierto solo mientras el tunnel está activo
  2. OpenSSH: permitlisten restringe al puerto asignado a nivel kernel
  3. Usuario de sistema: shell=/bin/false, no puede ejecutar nada

El servidor monitorea la salud del tunnel con challenge/response DSA firmado. Si falla 2 veces: kill -9 del proceso SSH + userdel + cierre del puerto en el firewall.


Abstracción Multi-Protocolo en el Gateway

Los tres protocolos inalámbricos tienen modelos de comunicación completamente distintos, unificados detrás de la misma interfaz:

┌─────────────────────────────────────────────────────────────────────┐
│  GatewayApiService (Facade)                                         │
│  Recibe comandos REST/MQTT, rutea al protocolo correcto             │
└───────────────┬────────────────┬─────────────────┬──────────────────┘
                │                │                 │
       ┌────────▼───────┐ ┌──────▼──────┐ ┌───────▼───────────┐
       │  ZWaveController│ │ZigbeeControl│ │ MatterController  │
       └────────┬────────┘ └──────┬──────┘ └───────┬───────────┘
                │                 │                 │
       ┌────────▼────────┐ ┌──────▼──────┐ ┌───────▼───────────┐
       │  ZWaveInterface │ │ZigbeeInterface│ │ MatterInterface  │
       │  UDP/IPv6 binary│ │subprocess CLI│ │ WebSocket JSON-RPC│
       │  fd00:bbbb::N   │ │Z3Gateway     │ │ python-matter-serv│
       └─────────────────┘ └─────────────┘ └───────────────────┘
ProtocoloTransporteFormatoDesafío principal
Z-WaveUDP IPv6Frames binarios propietariosCodec binario, ACK bitmasks, IPv6 normalization
Zigbeestdin/stdout de proceso hijoTexto con regexParser de CLI, frames multi-línea, subprocess lifecycle
MatterWebSocketJSON-RPCRespuestas de 10 MB, reconnect backoff, watchdog timer

Cada protocolo implementa el mismo patrón de hooks: ConcurrentHashMap de futures con predicados para matching, orTimeout uniforme de 10s.


Arquitectura de Seguridad

┌──────────────────────────────────────────────────────────────────────┐
│  FRONTEND → CLOUD-SIDE                                               │
│                                                                      │
│  • HTTPS obligatorio                                                 │
│  • JWT Bearer token (HS256/RS256, 1h expiry)                        │
│  • BCrypt para passwords en base de datos                            │
│  • GatewayOwnershipFilter: verifica por request que el usuario      │
│    sea dueño del gateway (no en el token — se agregan post-login)   │
│  • AdminApiKeyFilter: header X-Admin-Key para registrar gateways    │
└──────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│  CLOUD-SIDE → AWS IoT                                                │
│                                                                      │
│  • SigV4 signing sobre WebSocket (credenciales IAM)                 │
│  • DefaultCredentialsProvider: IAM role → env vars → ~/.aws        │
└──────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│  GATEWAY → AWS IoT                                                   │
│                                                                      │
│  • mTLS con certificados X.509 por dispositivo                      │
│  • Certificados en archivo con permisos 0400                        │
│  • Private key solo en /tmp/ (no persiste entre boots)              │
│  • Provisioning JITR: certificados efímeros → permanentes           │
└──────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│  SSH TUNNELING                                                       │
│                                                                      │
│  • authorized_keys con permitlisten por puerto                      │
│  • Shell /bin/false para usuarios de sistema                        │
│  • Puertos en Lightsail firewall solo mientras tunnel activo        │
│  • Challenge/response DSA firmado para health check                 │
└──────────────────────────────────────────────────────────────────────┘

Patrones de Diseño Transversales

Event-Driven con Report Handlers

En el gateway, los dispositivos generan eventos no solicitados (sensor disparado, batería baja, puerta abierta):

Dispositivo físico
    ↓ (frame UDP / línea CLI / mensaje WebSocket)
Protocol Interface (background thread / go-loop)

ReportHandler (interpreta el evento, actualiza estado)

DeviceService.update() (persiste en SQLite / EDN atom)

TelemetryBuffer (acumula eventos)
    ↓ (flush cada 60s, máx 500 eventos)
MQTT publish → iot/v1/{gwId}/event/{timestamp}

cloud-side → DynamoDB save (async, fire-and-forget)

Facade para API Unificada

GatewayApiService (Java) y la capa de controllers del proxy Clojure actúan como facades: los callers no saben qué protocolo están usando. El dispatch se hace internamente según device.protocol.

Factory con Fallback para Device Descriptors

DeviceDescriptorService carga templates que describen cómo configurar automáticamente un dispositivo al incluirlo:

1. Buscar en filesystem (/root/devices/) → override del operador
2. Si no existe, buscar en classpath (/resources/devices/) → default bundleado
3. Match por: manufacturerId + productTypeId + productId (Z-Wave)
              manufacturer + modelId, case-insensitive (Zigbee)
4. Cache en memoria por protocolo

Two-Tier con Routing Transparente

Query request con from/to

¿Cruza el límite de 48h?
  ├── Sí → DynamoDB (hot) + Athena (cold) en paralelo → merge
  ├── Todo cold → solo Athena
  └── Todo hot → solo DynamoDB

Scheduled Jobs para Mantenimiento

2:00 AM UTC diario    → ArchiveService: DynamoDB → S3 Parquet
Cada 60 segundos      → TelemetryBuffer.flush(): buffer → MQTT
Cada 60 segundos      → PendingRequestsService.cleanup(): futures vencidos
Cada 5 minutos        → MatterInterface watchdog: detecta TCP drops silenciosos
Cada N segundos       → SSH tunnel health check con challenge/response
Al arrancar           → PortPoolService.reconcile(): recupera estado desde DB

Decisiones de Arquitectura Relevantes

¿Por qué MQTT y no HTTP directo cloud→gateway?

Los gateways están detrás de NAT (routers domésticos). No tienen IP pública ni puerto abierto. MQTT con AWS IoT invierte la conexión: el gateway conecta hacia afuera y mantiene la sesión abierta. El cloud puede enviar comandos a través de esa sesión sin necesidad de port-forwarding.

¿Por qué dos implementaciones del gateway (Clojure + Java)?

El proyecto empezó en Clojure. La versión Java es una reescritura con más protocolos (agrega Matter), mejor estructura (Spring Boot DI, testing), y soporte para OTA updates. Ambas versiones están en producción en distintas generaciones de hardware.

¿Por qué SQLite en el gateway?

El gateway corre en hardware embebido (Raspberry Pi / similar) con recursos limitados. SQLite no requiere proceso separado, se embebe en el JAR, y tiene transacciones ACID. La limitación de una sola conexión es aceptable para un gateway single-user.

¿Por qué DynamoDB + S3/Athena y no solo PostgreSQL?

El VPS cloud tiene ~415 MB de RAM disponible. PostgreSQL con años de telemetría IoT (1 msg/min × N gateways) crecería indefinidamente. DynamoDB tiene TTL automático, S3 es barato para almacenamiento masivo, y Athena permite queries SQL sin servidor adicional.

¿Por qué no Kafka para telemetría?

Mismo motivo de recursos. Kafka requeriría un cluster separado o al menos 512 MB adicionales. El patrón directo MQTT → DynamoDB (fire-and-forget) es suficiente para la escala actual.

¿Por qué React Query y no Redux?

El estado principal de la app es server state. React Query lo maneja automáticamente con cache, stale time y refetch. Redux hubiera requerido actions/reducers/thunks para lograr lo mismo con más boilerplate. El estado global real (auth) se maneja con Context.


Tecnologías por Capa

CapaLenguajeFrameworkStorageComunicación
FrontendJavaScriptReact + VitelocalStorage (token)HTTPS REST
Cloud BackendJavaSpring Boot 4PostgreSQL + DynamoDB + S3HTTPS, MQTT5 WebSocket
Cloud ProxyClojurehttp-kit + CompojureDynamoDB (lectura)HTTPS, MQTT5 WebSocket
Gateway (Java)JavaSpring Boot 4SQLiteMQTT5 mTLS, UDP, subprocess, WebSocket
Gateway (Clojure)Clojurehttp-kit + CompojureEDN archivoMQTT5 mTLS, UDP, subprocess
Protocolo Z-Wavezipgateway (SilLabs)UDP IPv6 binario
Protocolo ZigbeeZ3Gateway (SilLabs)Serial / subprocess
Protocolo MatterPythonpython-matter-serverWebSocket JSON-RPC