❮  Entrixy controllers

BLE protocol specification

Everything you need to add Entrixy BLE support to your own controller — your board, your microcontroller. Our ready firmware does exactly this; below is the precise protocol description and test vectors so you can check yourself byte for byte.

A BLE controller does not need the Entrixy server. It never goes online at all: the phone talks to it directly over Bluetooth. Binding to an owner is local (X25519 ECDH). Registering the device with our server, obtaining keys or opening a manufacturer account is not required — you can ship empty controllers. Reference: esp32-example/entrixy-ble (MIT).

1. Cryptographic primitives

Reference: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) on the ESP, BouncyCastle on Android — both sides agree.

2. GATT: service and characteristics

All UUIDs are 128-bit, based on 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" in ASCII).

UUID suffixPropertyPurpose
0000Service UUID (full: 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREADan 8-byte nonce (legacy; a modern client takes the nonce from the advertisement, §3)
0002 FIREWRITEThe open command: 24 bytes (owner) or 47 bytes (guest)
0003 TIMEWRITEClock correction, exactly 26 bytes
0004 WIFIWRITESSID and password for NTP (optional)
0005 RESULTREAD+NOTIFYA one-byte result code after every write (§9)
00f0 PAIR_PUBREADPairing mode only: the controller's 32-byte X25519 public key (LE)
00f1 PAIR_DONEWRITEPairing mode only: 36 bytes [phone_pub 32B][device_id 4B LE]
The name "entrixy" and the Service UUID go into the scan-responserather than the primary packet: a 128-bit UUID plus manufacturer data will not fit into the 31-byte primary. Android passive scanning sees only the primary, so filtering is done on the manufacturer data (company id 0x00E0).

3. GAP advertisement

Once every sleep_interval seconds the controller advertises its presence with manufacturer-specific data. The company ID is 0x00E0 (on the wire E0 00). The block is 23 bytes = 2 bytes of company ID plus 21 bytes of value:

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)

The signature covers 13 bytes (the company ID is not included):

mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4)
auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
These 8 bytes auth_hmac serve as the nonce for an immediate open with no round trip: an observer without owner_secret sees them as random, while the owner or guest computes the signature and fires straight away. The controller keeps a ring of the last 16 nonces it issued.

Options (opts) — XOR keystream

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

Bistable mode

For a bistable drive three bytes are appended to the value, giving 26 bytes (see §8).

4. Pairing

A new controller enters pairing mode by itself for 30–90 s (otherwise by button; a factory reset is a 5-second hold). The exchange is local, with no server:

  1. The phone reads PAIR_PUB (00f0) — the controller's 32-byte public key.
  2. The phone generates its own X25519 pair, shared = X25519(phone_priv, esp_pub).
  3. Both sides derive the same secret:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. The phone writes PAIR_DONE (00f1): [phone_pub 32B][device_id 4B LE]. the phone generates the device_id, and the controller stores it.
X25519 keys are little-endian (RFC 7748). owner_secret never goes over the air. No server is involved: the app generated the device_id itself, which is how it knows it.

5. Opening by the owner (owner fire)

A write to FIRE (0002), 24 bytes:

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

nonce — the 8-byte auth_hmac from a fresh advertisement. The controller checks the nonce against its ring, verifies the signature, pulses the relay and invalidates the nonce.

6. Opening by a guest (guest fire)

A guest does not know owner_secret. The owner issues a bundle through the server: token, owner_sig, guest_key. A write to FIRE, 47 bytes: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].

FieldSizeFormula
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Ba fresh nonce from the advertisement
guest_sig16BHMAC-SHA256(guest_key, nonce)[0..15]
guest_key = HKDF-SHA256(salt=null, ikm=owner_secret, info="guest" || bleId(2B LE), L=32)

The controller: nonce in the ring → owner_sig with its own owner_secretvalid_until against its clock → derives guest_keyguest_sig. It keeps no list of guests — it only verifies the signature.

7. Time synchronisation

A guest TTL needs a clock. Any trusted client can set the time with a signature. A write to TIME (0003), 26 bytes:

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

Guest time is protected by a ratchet (no going back), a forward cap (no more than +24 h per write) and a fire-first gate. An external RTC and NTP are optional.

8. Bistable state (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

Two bytes of tag are deliberate: the state byte only drives the open/closed indicator and grants no authority, while every extra byte of advertisement costs battery on every wake-up. The tag guards against corruption on the air, not against a forger — 16 bits fall to brute force in seconds. Nothing that decides access may travel this way.

9. Response codes (RESULT, characteristic 0005)

CodeValueCodeValue
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — OPEN0x11TIME OK (guest)
0x31FIRE OK — CLOSED0x12TIME has the wrong length
0x01the nonce is stale or not in the ring0x13the TIME HMAC did not match
0x02the TTL has expired0x15TIME went backwards (ratchet)
0x03owner_sig is invalid0x16TIME exceeds the +24 h cap
0x04guest_sig is invalid0x17TIME without a recent fire
0x05wrong length0x20WIFI OK
0x06HKDF failed0x21WIFI has the wrong format
0x07the owner HMAC is invalid0x22the WIFI HMAC did not match

10. Test vectors

Deterministic known-answer vectors: run your implementation and compare the bytes. All values are hex and the byte order is as on the wire. The chain runs end to end: pairing → owner_secret → everything else.

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)

Reference code and contact

A working implementation of both sides is esp32-example/entrixy-ble (MIT). To build .bin for your own board, use the browser configurator. The online transport is socket protocol. How this fits into the open architecture: /open. OEM enquiries: hello@entrixy.com.