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.
device_key for volume production is covered in §10. Reference: esp32-ws-example (MIT).
1. Transport and message envelope
- Endpoint:
wss://entrixy.com/ws(hostentrixy.com, port 443, path/ws). In the open model the host comes from the key or a setting — see /open. - TLS: plain WSS. No subprotocol is negotiated.
- Frames: text (UTF-8) JSON. The discriminator is the field
type. - Reconnection: a fixed interval of 2 s in the reference ESP32 firmware. RX watchdog: no incoming frames for three ping periods (3 × ping_s, 30 s with the defaults) → restart.
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" }
| event | When |
|---|---|
button | the exit button inside was pressed; for a bistable drive it alternates open / close |
limit | the 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 |
auto | the 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
| Who | token | Check |
|---|---|---|
| Owner | empty | proof == HMAC-SHA256(ownerSecret, nonce)[0..15] |
| Guest | 3 bytes | verify 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
- HMAC-SHA256 — signatures; truncation takes the first 16 bytes.
- HKDF-SHA256 (RFC 5869),
salt=null→ 32 zero bytes. - Socket opening uses no AES/GCM — only HMAC and HKDF. (AES-256-GCM belongs to the separate subsystem that encrypts guest bundles, not here.)
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.
- For a prototype or a one-off, today: the owner creates a Device object in the app, receives the pair, and the configurator bakes it into the firmware. That is enough to bring the whole protocol up and test it.
- For volume production: the claim model — a device leaves the factory belonging to nobody, and the buyer binds it to their account with a code or QR. Batches of codes and stickers are issued in the manufacturer account: you apply, and once approved you generate a batch of
device_key, receive a CSV of codes and ready-made QR stickers. For questions about terms write to hello@entrixy.com.
A working implementation of both sides is esp32-ws-example (MIT). To build .bin — see browser configurator. How this fits the open architecture: /open.