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.
Componentes
| Sección | Descripción |
|---|---|
| Visión general | Diagrama de las cuatro capas y sus tecnologías |
| Flujo de un comando | Los 16 pasos desde click en la UI hasta el dispositivo físico |
| Patrón hook request/response | Cómo se convierte MQTT fire-and-forget en API síncrona |
| AWS IoT Core como bus | Tópicos MQTT, autenticación dual mTLS/SigV4 |
| Persistencia por capa | PostgreSQL, DynamoDB, S3/Athena, SQLite, EDN |
| Provisioning JITR | Registro automático de un gateway nuevo sin intervención humana |
| SSH Tunneling | Acceso remoto sin port-forwarding ni IP pública |
| Multi-protocolo en gateway | Z-Wave (UDP binario), Zigbee (CLI), Matter (WebSocket JSON-RPC) |
| Seguridad | JWT, BCrypt, mTLS, SigV4, SSH hardening |
Decisiones de diseño
| Pregunta | Respuesta 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 |
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) │
└───────────────┘ └───────────────┘ └───────────────────────────────┘
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).
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:
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:
| Proyecto | Tecnología | Timeout |
|---|---|---|
| Clojure proxy | core.async/alt!! + channel | 10s |
| Clojure gateway | core.async/alt!! + channel | 30s |
| Java cloud-side | CompletableFuture.orTimeout() | 30s |
| Java gateway-side | CompletableFuture.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.
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)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.
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 │
└─────────────────────────────────────────────────────────────┘
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.
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│
└─────────────────┘ └─────────────┘ └───────────────────┘
| Protocolo | Transporte | Formato | Desafío principal |
|---|---|---|---|
| Z-Wave | UDP IPv6 | Frames binarios propietarios | Codec binario, ACK bitmasks, IPv6 normalization |
| Zigbee | stdin/stdout de proceso hijo | Texto con regex | Parser de CLI, frames multi-línea, subprocess lifecycle |
| Matter | WebSocket | JSON-RPC | Respuestas 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.
┌──────────────────────────────────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────────────────────────────────┘
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)
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.
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
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
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
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.
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.
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.
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.
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.
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.
| Capa | Lenguaje | Framework | Storage | Comunicación |
|---|---|---|---|---|
| Frontend | JavaScript | React + Vite | localStorage (token) | HTTPS REST |
| Cloud Backend | Java | Spring Boot 4 | PostgreSQL + DynamoDB + S3 | HTTPS, MQTT5 WebSocket |
| Cloud Proxy | Clojure | http-kit + Compojure | DynamoDB (lectura) | HTTPS, MQTT5 WebSocket |
| Gateway (Java) | Java | Spring Boot 4 | SQLite | MQTT5 mTLS, UDP, subprocess, WebSocket |
| Gateway (Clojure) | Clojure | http-kit + Compojure | EDN archivo | MQTT5 mTLS, UDP, subprocess |
| Protocolo Z-Wave | — | zipgateway (SilLabs) | — | UDP IPv6 binario |
| Protocolo Zigbee | — | Z3Gateway (SilLabs) | — | Serial / subprocess |
| Protocolo Matter | Python | python-matter-server | — | WebSocket JSON-RPC |