❮  Entrixy controllers

Socket protocol specification

Everything you need to add Entrixy online mode to your own controller: the WebSocket messages, end-to-end encryption and test vectors. Our own boards that speak it are the NET line; the ones that work over Bluetooth alone are the BLE line.

Unlike BLE, a socket controller keeps a permanent connection to the Entrixy server over WebSocket and can be opened from anywhere. The basic mode is fully described by this spec. For end-to-end encryption, where the server cannot forge an opening, see §5–7. Obtaining a device_key for volume production is covered in §10. Reference: esp32-ws-example (MIT).

1. Transport and message envelope

The reference firmware for the ESP32 and the ESP8266 checks the server certificate against the Let’s Encrypt roots (ISRG Root X1 and X2) and sets its clock over NTP to check the certificate’s dates. A server whose certificate comes from another authority puts its root into SERVER_CA in the firmware. With SERVER_CA left empty, the firmware connects without checking the server.

2. Connection and authentication

The controller introduces itself in the first frame:

→ 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 — the two 32-character hex halves of the device code (with an optional ENTX-DEV:prefix). The server checks sha256(device_secret) against the hash by 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

A controller that works over the internet holds its own connection to the server, so the server sees the controller’s IP address and the time of each command sent to it. Only the ESP32 network controller bound to the owner’s phone checks the signature of every command (§5–6). The ESP8266, Raspberry Pi and Node.js controllers trust the server: it can send them an opening itself.

3. Opening (basic mode, without E2EE)

❮  device_command
{ "type":"device_command", "action":"open", "command_id":"<id>", "number_id":<n> }
  // action:"close" — for a bistable drive

The controller pulses the relay (1000 ms by default) and replies:

→ device_status
{ "type":"device_status", "command_id":"<id>",
  "level":"success"|"warning"|"danger"|"info",
  "message":"...", "final":true
  [, "position":"open"|"closed"|"opening"|"closing"|"unknown" ] }

level outside the whitelist is treated as info. The app shows message word for word and writes it into the log next to the press.

The controller also reports what happens on it by itself. Such a device_status has an empty command_id and an event field; the app writes it into the owner's log.

→ { "type":"device_status", "command_id":"", "event":"button"|"limit"|"auto",
    "level":"success", "message":"Opened", "final":true, "position":"open" }
eventWhen
buttonthe exit button inside was pressed; for a bistable drive it alternates open / close
limitthe limit switch: the door was moved by hand, or the drive did not get where a press sent it within the travel time set in the controller (level danger, message "Did not close" or "Did not open"). When the switch confirms a press, the event carries "confirm":true: the app updates that press in the log instead of adding a line
autothe controller closed by itself after the configured number of seconds

With a limit switch the controller reports opening or closing from the pulse until the switch confirms the position. Without one it reports the target position at once.

"hold":true asks the app to keep the message on the card until the controller's next one; without it the message goes after a few seconds. The controllers set it for Opening and Closing (level info, the door is on its way) and for a failure.

The reply to a command that really started the drive carries "started":true. The server waits for the limit switch to end only such a press: "Busy" or "already opening" for a second press does not take over the end of someone else's movement.

4. Heartbeat and liveness

The server does not ping devices — the fan-out does not scale. The device keeps the connection alive:

→ { "type":"ping" }    ← { "type":"pong" }     // the device sends this every ping_s seconds

If a device stays silent for longer than the server allows, the server disconnects it and shows it offline. The server tells the period in device_ok.ping_s: a third of that time (DEVICE_OFFLINE_S, 30 s by default). A device that says "ping_ctl":true in device_hello follows it; one that does not pings every 30 s and goes offline after 90 s of silence. A ping from the server is answered by the device with pong. For a bistable drive there is an object-state heartbeat: a spontaneous device_status without command_id with position every hb_interval seconds.

5. E2EE: provisioning the ownerSecret

End-to-end encryption ensures that the server cannot forge an opening — the command is signed by the app. ownerSecret (32 bytes) is not baked into the firmware: the owner's app delivers it over USB serial:

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)

After PROVISION or WIPE the controller reconnects with an updated e2ee. A provisioned controller rejects a plain device_command — only §6 applies.

6. E2EE: challenge-response opening

With e2ee=true instead of device_command there is a challenge-response. Freshness comes from a single-use nonce, not from time:

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
WhotokenCheck
Owneremptyproof == HMAC-SHA256(ownerSecret, nonce)[0..15]
Guest3 bytesverify owner_sig, derive guest_key, verify 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]

A close for a bistable drive carries "action":"close" and is signed over the nonce with the five ASCII bytes close appended: proof = HMAC-SHA256(key, nonce || "close")[0..15]. Here key is the owner's ownerSecret or the guest's guest_key. An open is signed over the bare nonce. The server therefore can turn neither an open into a close nor the other way: the signature would not match. The one who signs decides what to do, by the position the controller reported.

The controller accepts a nonce for 30 seconds after issuing it, and only on the connection that issued it: a signed command held back by the server does not work later.

Binary fields are base64. The nonce is 16 bytes and signatures are truncated to 16. The HKDF salt=null is 32 zero bytes.

7. Revoking a guest

❮  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]

The controller applies it only when version is greater than the stored one — monotonic, so the server can neither forge it nor bring access back.

The ESP32 network controller keeps up to 96 guest decisions. When the list is full, an allowed entry makes room for a new revocation; when all 96 are revocations, the oldest one is dropped.

8. Cryptographic primitives

The derivation of guest_key and the signatures are identical to the guest branch of BLE — the cryptography is reused.

9. Test vectors (E2EE)

Known-answer vectors. Binary fields are given in both hex and base64; the frames carry base64.

ownerSecret (32B)  = a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf
fingerprint        = b358977a   = HMAC(ownerSecret,"fp")[0..3]
nonce (16B)        = 000102030405060708090a0b0c0d0e0f   b64 = AAECAwQFBgcICQoLDA0ODw==

Owner fire (empty token)

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==

Close (bistable drive)

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. Obtaining a device_key (for OEMs)

For the server to recognise a device and know whose it is, you need the pair device_key/device_secret, known to the server and bound to the owner's account.

A working implementation of both sides is esp32-ws-example (MIT). To build .bin — see browser configurator. How this fits the open architecture: /open.