❮  Controladores Entrixy

Especificación del protocolo BLE

Todo lo necesario para añadir compatibilidad BLE de Entrixy a tu propio controlador: tu placa, tu microcontrolador. Nuestro firmware ya hecho hace exactamente esto; abajo está la descripción precisa del protocolo y los vectores de prueba para que te compruebes byte a byte.

Un controlador BLE no necesita el servidor de Entrixy. No se conecta nunca a internet: el teléfono habla con él directamente por Bluetooth. La vinculación con un propietario es local (X25519 ECDH). Registrar el dispositivo en nuestro servidor, obtener claves o abrir una cuenta de fabricante no es necesario — puedes enviar controladores vacíos. Referencia: esp32-example/entrixy-ble (MIT).

1. Primitivas criptográficas

Referencia: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) en el ESP, BouncyCastle en Android: ambos lados coinciden.

2. GATT: servicio y características

Todos los UUID son de 128 bits y se basan en 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" en ASCII).

Sufijo del UUIDPropiedadPara qué sirve
0000UUID del servicio (completo: 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREADun nonce de 8 bytes (heredado; un cliente moderno toma el nonce del anuncio, §3)
0002 FIREWRITELa orden de apertura: 24 bytes (propietario) o 47 bytes (invitado)
0003 TIMEWRITECorrección del reloj, exactamente 26 bytes
0004 WIFIWRITESSID y contraseña para NTP (opcional)
0005 RESULTREAD+NOTIFYUn código de resultado de un byte tras cada escritura (§9)
00f0 PAIR_PUBREADSolo en modo de emparejamiento: la clave pública X25519 de 32 bytes del controlador (LE)
00f1 PAIR_DONEWRITESolo en modo de emparejamiento: 36 bytes [phone_pub 32B][device_id 4B LE]
El nombre "entrixy" y el UUID del servicio van al scan-responseen lugar del paquete primario: un UUID de 128 bits más los datos de fabricante no caben en los 31 bytes del primario. El escaneo pasivo de Android solo ve el primario, así que el filtrado se hace por los datos de fabricante (id de compañía 0x00E0).

3. Anuncio GAP

Cada sleep_interval segundos, el controlador anuncia su presencia con datos específicos de fabricante. El id de compañía es 0x00E0 (en el aire E0 00). El bloque ocupa 23 bytes = 2 bytes de id de compañía más 21 bytes de valor:

offset  size  field
[0..1]   2    Company ID = E0 00
[2..5]   4    device_id            (LE)
[6..9]   4    counter              (LE, monotonic, anti-replay, survives deep sleep)
[10..17] 8    auth_hmac            = HMAC(owner_secret, mac_in)[0..7]
[18]     1    sleep_interval_s     (plaintext)
[19..22] 4    opts_cipher          (battery, status, fw, hw — encrypted)

La firma cubre 13 bytes (el id de compañía no se incluye):

mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4)
auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
Estos 8 bytes auth_hmac hacen de nonce para una apertura inmediata sin ida y vuelta: un observador sin owner_secret los ve como aleatorios, mientras que el propietario o el invitado calcula la firma y dispara al momento. El controlador mantiene un anillo con los últimos 16 nonces que emitió.

Opciones (opts): flujo de clave XOR

ks = HMAC-SHA256(owner_secret, "ks" || device_id(4 LE) || counter(4 LE))[0..3]
opts_cipher[i] = opts_plain[i] XOR ks[i]
opts_plain[0] = battery %   [1] = status   [2] = fw (major=(b>>4)&0xF, minor=b&0xF)   [3] = hw

Modo biestable

Para un motor biestable se añaden tres bytes al valor, con lo que quedan 26 bytes (ver §8).

4. Emparejamiento

Un controlador nuevo entra solo en modo de emparejamiento durante 30–90 s (si no, con el botón; el reinicio de fábrica es una pulsación de 5 segundos). El intercambio es local, sin servidor:

  1. El teléfono lee PAIR_PUB (00f0): la clave pública de 32 bytes del controlador.
  2. El teléfono genera su propio par X25519, shared = X25519(phone_priv, esp_pub).
  3. Ambos lados derivan el mismo secreto:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. El teléfono escribe PAIR_DONE (00f1): [phone_pub 32B][device_id 4B LE]. el teléfono genera el device_id, y el controlador lo guarda.
Las claves X25519 son little-endian (RFC 7748). owner_secret nunca viaja por el aire. No interviene ningún servidor: la aplicación generó el device_id ella misma, por eso lo conoce.

5. Apertura por el propietario (owner fire)

Una escritura en FIRE (0002), 24 bytes:

[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]

nonce — los 8 bytes auth_hmac de un anuncio reciente. El controlador comprueba el nonce contra su anillo, verifica la firma, da un pulso al relé e invalida el nonce.

6. Apertura por un invitado (guest fire)

Un invitado no conoce owner_secret. El propietario emite un paquete a través del servidor: token, owner_sig, guest_key. Una escritura en FIRE, 47 bytes: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].

CampoTamañoFórmula
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Bun nonce reciente del anuncio
guest_sig16BHMAC-SHA256(guest_key, nonce)[0..15]
guest_key = HKDF-SHA256(salt=null, ikm=owner_secret, info="guest" || bleId(2B LE), L=32)

El controlador: nonce en el anillo → owner_sig con su propia owner_secretvalid_until contra su reloj → deriva guest_keyguest_sig. No mantiene ninguna lista de invitados: solo verifica la firma.

7. Sincronización de la hora

El TTL de un invitado necesita un reloj. Cualquier cliente de confianza puede fijar la hora con una firma. Una escritura en TIME (0003), 26 bytes:

[bleId 2B LE][epoch_ms 8B LE][ HMAC-SHA256(key, payload[0..9])[0..15] 16B ]

La hora del invitado está protegida por un trinquete (no puede retroceder), un tope hacia delante (no más de +24 h por escritura) y una barrera de fire-first. Un RTC externo y NTP son opcionales.

8. Estado biestable (K_state)

K_state = HKDF-SHA256(salt=null, ikm=owner_secret, info="ble-state-v1", L=16)
[..21] state_enc = state_byte XOR K_state[counter & 15]     // bit0: 1=open, 0=closed
[22..23] state_mac = HMAC-SHA256(K_state, counter(4B LE) || state_enc)[0..1]   // 2 bytes

9. Códigos de respuesta (RESULT, característica 0005)

CódigoValorCódigoValor
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — ABIERTO0x11TIME OK (guest)
0x31FIRE OK — CERRADO0x12TIME tiene una longitud incorrecta
0x01el nonce está caducado o no está en el anillo0x13el HMAC de TIME no coincidió
0x02el TTL ha caducado0x15TIME ha retrocedido (trinquete)
0x03owner_sig no es válida0x16TIME supera el tope de +24 h
0x04guest_sig no es válida0x17TIME sin una apertura reciente
0x05longitud incorrecta0x20WIFI OK
0x06HKDF failed0x21WIFI tiene un formato incorrecto
0x07el HMAC del propietario no es válido0x22el HMAC de WIFI no coincidió

10. Vectores de prueba

Vectores deterministas con respuesta conocida: ejecuta tu implementación y compara los bytes. Todos los valores son hexadecimales y el orden de bytes es el del aire. La cadena va de punta a punta: emparejamiento → owner_secret → todo lo demás.

Pairing (X25519 → owner_secret)

esp_priv   (clamped) = 0002030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f60
phone_priv (clamped) = 201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030241
esp_pub              = 07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c
phone_pub            = 0d799600f6ffaee2e121e6b8f7a05dc66874b51db3102d0d71f799a09cb4c461
shared_z             = 53126e95ac6e407e8a412fdf82c87f1be45a2251edf9422ad00df2e83aaebd19
owner_secret         = 3609fa67bd15cf2bbaecdd5305feea0d48e1f21d714d01b29082a5fec459c64b
  = HKDF(salt="entrixy-pair-v1", ikm=shared_z, info="owner-secret", L=32)

Advertise (did=0x0A0B0C0D, counter=7, sleep=1, opts=batt100/status1/fw v2.3/hw5)

ks[0..3]     = ee0a8499
opts_cipher  = 8a0ba79c
auth_hmac    = 1f2d8012178a6e06   (= nonce)
mfg    (23B) = e0000d0c0b0a070000001f2d8012178a6e06018a0ba79c

Owner fire (24B)

fire = 1f2d8012178a6e06eaafaae40500580a774b62be6e9b6aa2
  = nonce || HMAC(owner_secret, nonce)[0..15]

Guest fire (47B) — bleId=0x0042, valid_until=0x6890ABCD, flags=0x01

token     (7B)  = 4200cdab906801
owner_sig (16B) = faa3a99a76dfba0521fb4eb4bd508559   = HMAC(owner_secret, token)[0..15]
guest_key (32B) = 248b57d94ccafeb3cba598c6b81fc18617c1b54da1386ca1ed99bcd5dd012ff5
  = HKDF(salt=null, ikm=owner_secret, info="guest"||bleId_LE, L=32)
guest_sig (16B) = 7a6e02ae269827b78b7c49840c40476e   = HMAC(guest_key, nonce)[0..15]
fire      (47B) = 4200cdab906801faa3a99a76dfba0521fb4eb4bd5085591f2d8012178a6e067a6e02ae269827b78b7c49840c40476e

Time-sync (26B) — owner (bleId=0), epoch_ms=1750000000000

time = 000000dc2074970100005e1120cdb0bb4c8f5ee22f2b1920cb99
  = [bleId 2B LE][epoch_ms 8B LE][ HMAC(owner_secret, payload)[0..15] ]

K_state (16B)

K_state = 19bcfc41f61a103d79e9967c21403a93
  = HKDF(salt=null, ikm=owner_secret, info="ble-state-v1", L=16)

Código de referencia y contacto

Una implementación funcional de ambos lados es esp32-example/entrixy-ble (MIT). Para compilar .bin para tu propia placa, usa el configurador del navegador. El transporte en línea es protocolo de socket. Cómo encaja esto en la arquitectura abierta: /open. Consultas OEM: hello@entrixy.com.