❮  Контроллеры Entrixy

Спецификация socket-протокола

Всё необходимое, чтобы встроить поддержку онлайн-режима Entrixy в свой контроллер: описание WebSocket-сообщений, E2EE и тест-векторы. Наши собственные платы, говорящие на нём, — линейка NET; те, что работают только по Bluetooth, — линейка BLE.

В отличие от BLE, socket-контроллер постоянно соединён с сервером Entrixy по WebSocket и открывается из любой точки. Базовый режим реализуется полностью по этой спеке. Для E2EE (сервер не может подделать открытие) — §5–7. Получение device_key для серийного производства — §10. Референс: esp32-ws-example (MIT).

1. Транспорт и конверт сообщений

Эталонная прошивка для ESP32 и ESP8266 проверяет сертификат сервера по корневым сертификатам Let’s Encrypt (ISRG Root X1 и X2) и сверяет часы по NTP, чтобы проверить сроки сертификата. Сервер с сертификатом другого центра кладёт свой корневой сертификат в SERVER_CA в прошивке. Если SERVER_CA оставить пустым, прошивка подключается без проверки сервера.

2. Подключение и аутентификация

Первым кадром контроллер представляется:

→ device_hello
{ "type":"device_hello",
  "device_key":    "<32 hex>",
  "device_secret": "<32 hex>",
  "e2ee":          true|false }        // есть ли валидный ownerSecret (§5)

device_key/device_secret — две 32-символьные hex-половины "кода устройства" (опц. префикс ENTX-DEV:). Сервер сверяет sha256(device_secret) с хэшем по 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

Контроллер, работающий через интернет, сам держит соединение с сервером, поэтому сервер видит IP-адрес контроллера и время каждой команды. Подпись каждой команды проверяет только сетевой контроллер на ESP32, привязанный к телефону владельца (§5–6). Контроллеры на ESP8266, Raspberry Pi и Node.js доверяют серверу: он может сам отправить им открытие.

3. Открытие (базовый режим, без E2EE)

❮  device_command
{ "type":"device_command", "action":"open", "command_id":"<id>", "number_id":<n> }
  // action:"close" — для бистабильного привода

Контроллер даёт импульс на реле (по умолчанию 1000 мс) и отвечает:

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

level вне белого списка → трактуется как info. Приложение показывает message дословно и пишет его в журнал рядом с нажатием.

Контроллер также сообщает, что происходит на нём самом. У такого device_status пустой command_id и есть поле event — приложение пишет его в журнал хозяина.

→ { "type":"device_status", "command_id":"", "event":"button"|"limit"|"auto",
    "level":"success", "message":"Opened", "final":true, "position":"open" }
eventКогда
buttonнажали кнопку выхода внутри; у бистабильного привода она чередует открыть / закрыть
limitконцевик: дверь двинули руками, или привод не доехал туда, куда его отправило нажатие, за время хода, заданное в контроллере (level danger, message "Did not close" или "Did not open"). Когда концевик подтверждает нажатие, событие несёт "confirm":true: приложение обновляет строку этого нажатия в журнале, а не добавляет новую
autoконтроллер закрылся сам через заданное число секунд

С концевиком контроллер сообщает opening или closing от импульса до подтверждения положения концевиком. Без концевика он сразу сообщает целевое положение.

"hold":true просит приложение держать сообщение на карточке до следующего сообщения контроллера; без него сообщение исчезает через несколько секунд. Контроллеры ставят его для Открытие и Closing (уровень info, ворота в пути) и для сбоя.

Ответ на команду, которая действительно запустила привод, несёт "started":true. Конца движения от концевика сервер ждёт только для такого нажатия: «Занято» или «уже открывается» на второе нажатие не перехватывают чужое движение.

4. Heartbeat и живость

Сервер не пингует устройства (fan-out не масштабируется). Живость держит устройство:

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

Если устройство молчит дольше, чем разрешает сервер, сервер отключает его и показывает «не в сети». Период сервер сообщает в device_ok.ping_s: треть этого времени (DEVICE_OFFLINE_S, по умолчанию 30 с). Устройство, приславшее "ping_ctl":true в device_hello , следует ему; остальные шлют ping раз в 30 с и считаются «не в сети» после 90 с молчания. На ping от сервера → устройство отвечает pong. Для бистабильного привода — object-state heartbeat: спонтанный device_status без command_id с position каждые hb_interval сек.

5. E2EE: провизионинг ownerSecret

E2EE делает так, что сервер не может подделать открытие — команду подписывает приложение. ownerSecret (32 байта) в прошивку не зашивается: приложение владельца передаёт его по USB-serial:

PROVISION <64 hex>   → сохранить ownerSecret в NVS, вернуть fingerprint
WIPE                  → стереть (базовый режим)
STATUS                → показать fingerprint
fingerprint = HMAC-SHA256(ownerSecret, "fp")[0..3]  (hex)

После PROVISION/WIPE контроллер переподключается с обновлённым e2ee. Провизенный контроллер отвергает обычный device_command — только §6.

6. E2EE: challenge-response открытие

При e2ee=true вместо device_command — challenge-response. Свежесть даёт одноразовый nonce, не время:

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
КтоtokenПроверка
Владелецпустойproof == HMAC-SHA256(ownerSecret, nonce)[0..15]
Гость3 байтасверить owner_sig, вывести guest_key, сверить proof
token (3B) = [guest_did 2B LE][perms 1B]                      // capability, без 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]

Закрытие бистабильного привода несёт "action":"close" и подписывается над nonce с дописанными пятью ASCII-байтами close в конце: proof = HMAC-SHA256(key, nonce || "close")[0..15]. Здесь key — это ownerSecret хозяина или guest_key гостя. Открытие подписывается над голым nonce. Поэтому сервер не может превратить открытие в закрытие и наоборот: подпись не сойдётся. Решает, что делать, тот, кто подписывает, — по положению, которое сообщил контроллер.

Контроллер принимает nonce 30 секунд после выдачи и только в том соединении, где его выдал: подписанная команда, придержанная сервером, позже не сработает.

Бинарные поля — base64. nonce 16 байт, подписи усечены до 16. HKDF salt=null = 32 нулевых байта.

7. Отзыв гостя

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

Контроллер применяет только при version больше сохранённого (монотонно — сервер не подделает и не воскресит доступ).

Сетевой контроллер на ESP32 хранит до 96 решений по гостям. Когда список заполнен, место для нового отзыва освобождает запись «разрешено»; если все 96 — отзывы, удаляется самый старый.

8. Крипто-примитивы

Деривация guest_key и подписи идентичны гостевой ветке BLE — крипто переиспользуется.

9. Тест-векторы (E2EE)

Known-answer векторы. Бинарные поля даны и hex, и base64 (в кадрах — base64).

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

Owner fire (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==

Закрытие (бистабильный привод)

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. Получение device_key (для OEM)

Чтобы сервер узнавал устройство и знал, какому владельцу оно принадлежит, нужна пара device_key/device_secret, известная серверу и привязанная к аккаунту владельца.

Рабочая реализация обеих сторон — esp32-ws-example (MIT). Собрать .bin — в браузерном конфигураторе. Как это в открытой архитектуре — /open.