Especificación del protocolo de socket
Todo lo necesario para añadir el modo en línea de Entrixy a tu propio controlador: los mensajes WebSocket, el cifrado de extremo a extremo y los vectores de prueba. Nuestras propias placas que lo hablan son la línea NET; las que funcionan solo por Bluetooth son la línea BLE.
device_key para producción en serie se explica en §10. Referencia: esp32-ws-example (MIT).
1. Transporte y sobre del mensaje
- Endpoint:
wss://entrixy.com/ws(hostentrixy.com, puerto 443, ruta/ws). En el modelo abierto el host viene de la clave o de un ajuste; ver /open. - TLS: WSS a secas. No se negocia ningún subprotocolo.
- Tramas: JSON de texto (UTF-8). El discriminador es el campo
type. - Reconexión: intervalo fijo de 2 s en el firmware de referencia para ESP32. Watchdog de RX: tres periodos de ping sin tramas entrantes (3 × ping_s, 30 s con los valores por defecto) → reinicio.
2. Conexión y autenticación
El controlador se presenta en la primera trama:
→ device_hello
{ "type":"device_hello",
"device_key": "<32 hex>",
"device_secret": "<32 hex>",
"e2ee": true|false } // whether a valid ownerSecret exists (§5)
device_key/device_secret — las dos mitades hex de 32 caracteres del código del dispositivo (con un ENTX-DEV:prefijo opcional). El servidor comprueba sha256(device_secret) contra el hash por device_key.
❮ device_ok { "type":"device_ok", "hb_interval":300, "ping_s":10 } // success; hb_interval in s (10..3600), ping_s — the ping period
← error { "type":"error", "reason":"auth" } // failure → the server closes
Un controlador que funciona por internet mantiene su propia conexión con el servidor, así que el servidor ve la dirección IP del controlador y la hora de cada orden que se le envía. Solo el controlador de red ESP32 vinculado al teléfono del propietario comprueba la firma de cada orden (§5–6). Los controladores en ESP8266, Raspberry Pi y Node.js confían en el servidor: este puede enviarles una apertura por sí mismo.
3. Apertura (modo básico, sin E2EE)
❮ device_command
{ "type":"device_command", "action":"open", "command_id":"<id>", "number_id":<n> }
// action:"close" — for a bistable drive
El controlador da un pulso al relé (1000 ms por defecto) y responde:
→ device_status
{ "type":"device_status", "command_id":"<id>",
"level":"success"|"warning"|"danger"|"info",
"message":"...", "final":true
[, "position":"open"|"closed"|"opening"|"closing"|"unknown" ] }
level fuera de la lista blanca se trata como info. La app muestra message tal cual y lo escribe en el registro junto a la pulsación.
El controlador también informa de lo que ocurre en él por sí mismo. Un device_status así tiene un command_id vacío y un campo event — la app lo escribe en el registro del propietario.
→ { "type":"device_status", "command_id":"", "event":"button"|"limit"|"auto",
"level":"success", "message":"Opened", "final":true, "position":"open" }
| event | Cuándo |
|---|---|
button | se pulsó el botón de salida interior; en un accionamiento biestable alterna abrir / cerrar |
limit | el final de carrera: la puerta se movió a mano, o el motor no llegó dentro del tiempo de recorrido fijado en el controlador a donde lo envió una pulsación (level danger, message "Did not close" o "Did not open"). Cuando el final de carrera confirma una pulsación, el evento lleva "confirm":true: la app actualiza esa pulsación en el registro en lugar de añadir una línea |
auto | el controlador cerró por sí mismo tras el número de segundos configurado |
Con final de carrera, el controlador informa opening o closing desde el pulso hasta que el final de carrera confirma la posición. Sin él, informa de inmediato la posición objetivo.
"hold":true pide a la app mantener el mensaje en la tarjeta hasta el siguiente mensaje del controlador; sin él, el mensaje desaparece a los pocos segundos. Los controladores lo ponen en Apertura y Closing (nivel info, la puerta está en camino) y en un fallo.
La respuesta a un comando que realmente puso en marcha el motor lleva "started":true. El servidor espera el final del final de carrera solo para esa pulsación: «Ocupado» o «ya abriendo» ante una segunda pulsación no se apropia del movimiento de otro.
4. Heartbeat y detección de vida
El servidor no hace ping a los dispositivos: ese abanico no escala. Es el dispositivo el que mantiene viva la conexión:
→ { "type":"ping" } ← { "type":"pong" } // the device sends this every ping_s seconds
Si un dispositivo permanece en silencio más de lo que permite el servidor, el servidor lo desconecta y lo muestra sin conexión. El servidor indica el periodo en device_ok.ping_s: un tercio de ese tiempo (DEVICE_OFFLINE_S, 30 s por defecto). Un dispositivo que envía "ping_ctl":true en device_hello lo sigue; los demás hacen ping cada 30 s y quedan sin conexión tras 90 s de silencio. A un ping del servidor lo responde el dispositivo con pong. Para un motor biestable hay un heartbeat del estado del objeto: un device_status sin command_id con position cada hb_interval segundos.
5. E2EE: aprovisionamiento del ownerSecret
El cifrado de extremo a extremo garantiza que el servidor no puede falsificar una apertura — la orden la firma la aplicación. ownerSecret (32 bytes) no va incrustado en el firmware: lo entrega la aplicación del propietario por el puerto serie USB:
PROVISION <64 hex> → store the ownerSecret in NVS, return the fingerprint WIPE → erase it (basic mode) STATUS → show the fingerprint fingerprint = HMAC-SHA256(ownerSecret, "fp")[0..3] (hex)
Tras PROVISION o WIPE el controlador se reconecta con un e2ee actualizado. Un controlador aprovisionado rechaza un device_command sin más: solo se aplica §6.
6. E2EE: apertura por desafío-respuesta
Con e2ee=true en lugar de device_command se hace un desafío-respuesta. La frescura viene de un nonce de un solo uso, no del tiempo:
1. ← sock_challenge_req { "type":"sock_challenge_req", "command_id":"<id>" }
2. → sock_challenge { "type":"sock_challenge", "command_id":"<id>", "nonce":"<b64 16B>" }
3. ← sock_fire { "type":"sock_fire", "command_id","number_id",
"token":"<b64>", "owner_sig":"<b64>", "guest_id":<n>,
"nonce":"<b64>", "proof":"<b64>" [, "action":"close"] }
4. → device_status success
| Quién | token | Comprobación |
|---|---|---|
| Propietario | vacío | proof == HMAC-SHA256(ownerSecret, nonce)[0..15] |
| Invitado | 3 bytes | verificar owner_sig, derivar guest_key, verificar proof |
token (3B) = [guest_did 2B LE][perms 1B] // a capability, no TTL owner_sig = HMAC-SHA256(ownerSecret, token)[0..15] guest_key = HKDF-SHA256(salt=null, ikm=ownerSecret, info="guest"||guest_did(2B LE), L=32) proof = HMAC-SHA256(guest_key, nonce)[0..15]
Un cierre de un accionamiento biestable lleva "action":"close" y se firma sobre el nonce con los cinco bytes ASCII close añadidos: proof = HMAC-SHA256(key, nonce || "close")[0..15]. Aquí key es el ownerSecret del propietario o el guest_key del invitado. Una apertura se firma sobre el nonce solo. Así el servidor no puede convertir una apertura en un cierre ni al revés: la firma no coincidiría. Decide qué hacer quien firma, según la posición que informó el controlador.
El controlador acepta un nonce durante 30 segundos tras emitirlo y solo en la conexión en la que lo emitió: un comando firmado que el servidor retenga no funcionará después.
Los campos binarios van en base64. El nonce es de 16 bytes y las firmas se truncan a 16. El salt=null de HKDF son 32 bytes cero.
7. Revocar a un invitado
❮ sock_revoke { "type":"sock_revoke", "guest_id","version","revoked", "sig":"<b64>" }
→ sock_revoke_ack
sig = HMAC-SHA256(ownerSecret, "rev" || guest_id(2B LE) || version(4B LE) || revoked(1B))[0..15]
El controlador solo lo aplica cuando version es mayor que el guardado: es monótono, así que el servidor no puede ni falsificarlo ni devolver el acceso.
El controlador de red ESP32 guarda hasta 96 decisiones sobre invitados. Cuando la lista está llena, una entrada «permitido» deja sitio a una nueva revocación; si las 96 son revocaciones, se descarta la más antigua.
8. Primitivas criptográficas
- HMAC-SHA256 — firmas; el truncado toma los primeros 16 bytes.
- HKDF-SHA256 (RFC 5869),
salt=null→ 32 bytes cero. - La apertura por socket no usa AES/GCM: solo HMAC y HKDF. (AES-256-GCM pertenece al subsistema aparte que cifra los paquetes de invitado, no a esto.)
La derivación de guest_key y las firmas son idénticas a la rama de invitado de BLE — la criptografía se reutiliza.
9. Vectores de prueba (E2EE)
Vectores con respuesta conocida. Los campos binarios se dan en hex y en base64; en las tramas va base64.
ownerSecret (32B) = a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf fingerprint = b358977a = HMAC(ownerSecret,"fp")[0..3] nonce (16B) = 000102030405060708090a0b0c0d0e0f b64 = AAECAwQFBgcICQoLDA0ODw==
Apertura del propietario (token vacío)
proof (16B) = 3fc0619c684a8261d06c1501ae4e726a = HMAC(ownerSecret, nonce)[0..15] proof b64 = P8BhnGhKgmHQbBUBrk5yag==
Guest fire (guest_did=0x0042, perms=0x01)
token (3B) = 420001 b64 = QgAB owner_sig (16B) = 850a9f31fb708b176f4ed62a43acf0ad = HMAC(ownerSecret, token)[0..15] b64 = hQqfMftwixdvTtYqQ6zwrQ== guest_key (32B) = 614da70a34890a807a25f7c5f271e530434c478f144370a2b9efaf0c58cd85c1 = HKDF(salt=null, ownerSecret, info="guest"||did_LE, L=32) proof (16B) = a8a383ae56ac8a0182df2a275e220e83 = HMAC(guest_key, nonce)[0..15] b64 = qKODrlasigGC3yonXiIOgw==
Cierre (accionamiento biestable)
signed input = nonce || "close" = 000102030405060708090a0b0c0d0e0f636c6f7365 owner proof = ef15ca41823f126de0815f84c7dc8275 = HMAC(ownerSecret, input)[0..15] b64 = 7xXKQYI/Em3ggV+Ex9yCdQ== guest proof = 54f23d61766a274d8996c26e67925c32 = HMAC(guest_key, input)[0..15] b64 = VPI9YXZqJ02JlsJuZ5JcMg==
Revoke (guest_id=0x0042, version=3, revoked=1)
sig input = "rev"||guest_id_LE(2)||version_LE(4)||revoked(1) = 72657642000300000001 sig (16B) = 9433b96ddbb599f6c72bf17c0c9825fe b64 = lDO5bdu1mfbHK/F8DJgl/g==
10. Obtención de un device_key (para OEM)
Para que el servidor reconozca un dispositivo y sepa de quién es, hace falta el par device_key/device_secret, conocido por el servidor y ligado a la cuenta del propietario.
- Para un prototipo o una unidad suelta, hoy: el propietario crea un objeto de tipo Dispositivo en la aplicación, recibe el par, y el configurador lo incrusta en el firmware. Con eso basta para levantar todo el protocolo y probarlo.
- Para producción en serie: el modelo de reclamación (claim): el dispositivo sale de fábrica sin dueño y el comprador lo vincula a su cuenta con un código o QR. Los lotes de códigos y pegatinas se emiten en la cuenta de fabricante: presentas la solicitud y, una vez aprobada, generas un lote de
device_key, recibes un CSV con los códigos y pegatinas QR ya hechas. Para preguntas sobre las condiciones escribe a hello@entrixy.com.
Una implementación funcional de ambos lados es esp32-ws-example (MIT). Para compilar .bin — ver configurador del navegador. Cómo encaja esto en la arquitectura abierta: /open.