BLE без интернета

Открывать машину, дверь или шлагбаум прямо со смартфона по Bluetooth Low Energy. ESP32-C3 на батарейке месяцами, телефон подошёл — открылось. Сервера нет, FCM нет, WiFi не нужен.

← к интеграциям

Зачем

Базовая Entrixy-схема — это телефон → наш сервер → ESP. Работает везде где есть интернет, но в гараже, на парковке-минусе, в лифте сигнала может не быть. BLE-канал решает «последний метр» — телефон цепляется к ESP напрямую, без вмешательства сервера.

~300 мс
время отклика
(против 2 с через сервер)
единицы мкА
средний ток ESP32-C3
(годы на AA)
0
зависимость от
интернета и FCM
5–15 м
дальность
(настраивается порогом RSSI)

Как это работает

1. ESP-сторона

ESP32-C3 спит в deep-sleep. Раз в секунду просыпается, ~200 мс отвечает в BLE-эфире (advertise со своим device_id и подписанным счётчиком), снова в сон. Никаких WiFi, никаких MQTT. Один общий секрет с приложением хозяина в NVS.

2. Телефон-сторона

Один раз регистрируется системный BluetoothLeScanner.startScan(filters, settings, pendingIntent) с фильтром по нашему service-UUID. Дальше приложение может быть убито Android'ом — Bluetooth-чип сам сканирует на низких токах (так же работает обнаружение наушников), будит систему только когда поймал ESP. Сравнимо по потреблению с тем, что Android и так делает 24/7.

3. Срабатывание

Receiver проверяет подпись advertise, подключается к ESP, читает свежий nonce, шлёт подписанную HMAC fire-команду. ESP проверяет, дёргает реле (импульс 500 мс), обратно в сон. Весь цикл — 200–400 мс.

Получить прошивку

Самый простой путь — конфигуратор в браузере

Откройте /esp/ble/, выберите чип (ESP32-S3, C3, C6, WROOM), укажите пины и полярность реле, нажмите «Скомпилировать». Сервер за 25–60 секунд соберёт уникальный .bin под ваш конфиг, его можно скачать или прошить прямо из браузера через WebSerial. Никакого Arduino IDE, ничего ставить не надо.

Есть готовые пресеты под Shelly Plus 1 / 1PM (с дополнительными клеммами под автоматику ворот) и под самосбор с релейным модулем.

Если хочется собирать вручную — готовый Arduino-скетч, собирается из коробки в Arduino IDE 2.x с Arduino-core for ESP32 v3.x. Внешних библиотек не нужно — всё из штатного core (NimBLE, Preferences, esp_sleep, mbedtls).

⬇ собрать .bin в браузере скачать исходник .zip смотреть .ino

Безопасность

Привязка хозяина — только физически

Pair-режим открывается на 30 секунд и только в двух случаях: при первом включении непривязанного устройства (исключение, чтобы не плясать с кнопкой при первой установке) или по физическому нажатию кнопки на уже привязанном. После того как устройство привязано к хозяину — повторная привязка возможна только через long-press 5 сек на кнопке (factory reset, чистит секрет, открывает pair заново). Удалённо инициировать pair-режим невозможно.

Подпись каждой команды

Все advertise и все fire-команды подписаны HMAC-SHA256 общим секретом. Снифферу с BLE-аналайзером ничего не достать — ключ в эфир не уходит, advertise со счётчиком не повторяется, fire-команда привязана к одноразовому nonce.

Передача гостям

Хозяин выпускает гостю подписанный токен с TTL=24ч. Токен прилетает по существующему E2EE-каналу Entrixy. За 2 часа до истечения гостевое приложение само просит у хозяина продление (если хозяин в настройках не снял галочку «BLE-доступ»). Снял галочку → токен у гостя дотягивает до истечения и умирает. Никакого physical access к ESP для отзыва не нужно. На стороне ESP — ноль данных про гостей.

Полная спецификация протокола

Эта секция — однозначная битовая ссылка для разработчика, который хочет написать прошивку своего устройства под Entrixy. Все поля в little-endian. Все строки в HKDF — ASCII без терминирующего нуля.

BLE-имена и режимы

Рабочий режим  ·  имя entrixy. Раз в секунду ~200 мс в эфире, advertise + connectable.

Pair-режим  ·  имя entrixy-pair. Открывается на 30 секунд по физической кнопке (либо сразу при первом включении непривязанного устройства). Не открывается удалённо.

Service UUID

656e7472-7869-7900-0000-426c45000001

Характеристики

UUID                                       Mode   Operation
656e7472-7869-7900-0001-426c45000001       Work   READ   — NONCE (8 байт случайных)
656e7472-7869-7900-0002-426c45000001       Work   WRITE  — FIRE (24 или 47 байт)
656e7472-7869-7900-0003-426c45000001       Work   WRITE  — TIME-SYNC (24 байт)
656e7472-7869-7900-00f0-426c45000001       Pair   READ   — ESP public key (32 байт)
656e7472-7869-7900-00f1-426c45000001       Pair   WRITE  — phone pub + did (36 байт)

Advertise — manufacturer-specific data

Всего 18 байт на wire, включая 2-байтный Bluetooth SIG company ID. Android-парсер кладёт первые 2 байта в ключ SparseArray<byte[]> и фильтрует наш эфир по нему.

Offset  Size  Field
  0      2    Company ID (LE) = 0xE0 0x00      // на wire два байта подряд
  2      4    device_id (LE)                   // 32-bit, выдан приложением при pair
  6      4    counter (LE)                     // монотонно растёт, anti-replay
 10      8    HMAC-SHA256(owner_secret, advertise[2..9])[0..7]

Тонкий момент: HMAC подписывает только 8 байт — это конкатенация device_id и counter. Company ID в подпись не входит.

Fire от хозяина — 24 байта

Owner шлёт WRITE на CHAR_FIRE_UUID после READ NONCE:

Offset  Size  Field
  0      8    nonce (точно то что было прочитано из NONCE)
  8     16    HMAC-SHA256(owner_secret, nonce)[0..15]

Fire от гостя — 47 байт

Гость не имеет owner_secret. Вместо него — подписанный GuestToken от хозяина и производный ключ. WRITE на тот же CHAR_FIRE_UUID, диспатч по длине payload:

Offset  Size  Field
  0      7    GuestToken
  7     16    owner_sig = HMAC-SHA256(owner_secret, token)[0..15]
 23      8    nonce
 31     16    guest_sig = HMAC-SHA256(guest_key, nonce)[0..15]

GuestToken (7 байт):

Offset  Size  Field
  0      2    guest_did (LE)                   // 16-bit, идентифицирует гостя
  2      4    valid_until (LE, UNIX epoch sec) // после этой даты ESP откажет
  6      1    perms                            // 0x01 = fire-only, прочие зарезервированы

guest_key выводится через HKDF (см. ниже).

Time-sync — 24 байта

Хозяин подписанной командой корректирует RTC устройства. Без этого TTL гостевых токенов «уплывёт». Гости отправить time-sync не могут — подпись делается owner_secret'ом, у них только производный guest_key.

Offset  Size  Field
  0      8    epoch_ms (LE, UNIX epoch milliseconds)
  8     16    HMAC-SHA256(owner_secret, epoch_ms)[0..15]

Pair-flow — последовательность

┌──────────┐                              ┌──────────┐
│   ESP    │                              │  Phone   │
└────┬─────┘                              └────┬─────┘
     │                                         │
     │  (advertise name = "entrixy-pair")      │
     │ ◄────── BLE discovery + connect ──────  │
     │                                         │
     │ ─── READ  CHAR_PAIR_PUB_UUID ──────►    │
     │     [esp_pub 32B]                       │
     │                                         │
     │                       phone_priv ← random 32B (clamped по RFC 7748)
     │                       phone_pub  ← X25519_base(phone_priv)
     │                       shared_z   ← X25519(phone_priv, esp_pub)
     │                       owner_secret ← HKDF_SHA256(
     │                            salt = "entrixy-pair-v1",
     │                            ikm  = shared_z,
     │                            info = "owner-secret",
     │                            L    = 32)
     │                                         │
     │ ◄── WRITE CHAR_PAIR_DONE_UUID ──────    │
     │     [phone_pub 32B][device_id 4B LE]    │
     │                                         │
     │   shared_z = X25519(esp_priv, phone_pub)│
     │   owner_secret = HKDF(...)              │
     │   save NVS: {owner_secret, device_id,   │
     │              counter=0}                 │
     │                                         │
     │ ── BLE disconnect, leave pair-mode      │

Fire-flow (хозяин) — последовательность

┌──────────┐                              ┌──────────┐
│   ESP    │                              │  Phone   │
└────┬─────┘                              └────┬─────┘
     │                                         │
     │  advertise (раз в секунду 200мс)        │
     │ ──[mfg: company=0x00E0, did, ctr, hmac]►│
     │                                         │
     │     verify HMAC(owner_secret, did||ctr) │
     │     match did → connect                 │
     │                                         │
     │ ◄────── connect, discover ────────      │
     │                                         │
     │ ── READ  CHAR_NONCE_UUID ────────►      │
     │     [nonce 8B (random fresh)]           │
     │                                         │
     │     hmac = HMAC(owner_secret, nonce)    │
     │     payload = nonce || hmac[0..15]      │
     │                                         │
     │ ◄── WRITE CHAR_FIRE_UUID ───────────    │
     │     [nonce 8B][hmac[0..15] 16B]         │
     │                                         │
     │   verify nonce + HMAC → fire relay      │
     │   (опционально) WRITE TIME-SYNC для     │
     │   корректировки RTC                     │
     │                                         │
     │ ── disconnect ────────────────────►     │

Криптопримитивы

HMAC-SHA256  ·  RFC 2104, тэг 32 байта (мы используем первые 8 или 16). Реализации: mbedtls/md.h на ESP, javax.crypto.Mac("HmacSHA256") на Android.

HKDF-SHA256  ·  RFC 5869. Extract-then-expand на основе HMAC-SHA256. mbedtls/hkdf.h на ESP, своя реализация поверх Mac на Android.

X25519 / Curve25519  ·  RFC 7748. Ключи представляются как 32-байтные little-endian X-координаты. На ESP — mbedtls_ecp_* с MBEDTLS_ECP_DP_CURVE25519. На Android (minSdk 26) — org.bouncycastle.math.ec.rfc7748.X25519 (платформенный KeyPairGenerator("XDH") только с API 31).

HKDF — параметры

1) Owner secret из ECDH shared point:
     salt = "entrixy-pair-v1"      (15 байт ASCII)
     ikm  = shared_z               (32 байта)
     info = "owner-secret"         (12 байт ASCII)
     L    = 32

2) Guest key из owner_secret:
     salt = null/пустой            (зануляется до 32 нулей по RFC 5869)
     ikm  = owner_secret           (32 байта)
     info = "guest" || guest_did_LE   (5 + 2 = 7 байт)
     L    = 32

Test vectors

Чтобы проверить что ваша реализация HMAC сходится с нашей:

owner_secret = 0x00000000000000000000000000000000
               00000000000000000000000000000000          (32 нулевых байта)
nonce        = 0x0102030405060708                        (8 байт)

HMAC-SHA256(owner_secret, nonce) =
               c59a063ccd78f8ef7da2e361f5027145
               c495f0728f0575fe5fe55840daaefa51          (32 байта)

fire payload = nonce || HMAC[0..15] =
               0102030405060708
                 c59a063ccd78f8ef7da2e361f5027145         (24 байт)

Проверка в Python:

import hmac, hashlib
secret = bytes(32)
nonce  = bytes.fromhex("0102030405060708")
print(hmac.new(secret, nonce, hashlib.sha256).hexdigest())

Дальность

В настройках BLE-объекта в приложении — ползунок порога RSSI:

−60 дБм
~3 м
(машина рядом)
−70 дБм
~5 м
(дефолт)
−80 дБм
~10 м
(дверь подъезда)
−90 дБм
~20 м
(шлагбаум двора)

Что внутри прошивки

Скетч entrixy-ble.ino реализует полный протокол целиком:

ECDH X25519 в pair-flow. Хозяин и ESP обмениваются временными публичными ключами через GATT, оба вычисляют общий секрет через mbedtls. В эфир ключ не уходит — снифферу с BLE-аналайзером ничего не достать.

Гостевые токены с TTL. Отдельная ветка в fire-callback: парсит GuestToken (guest_did + valid_until + perms), проверяет owner_sig, проверяет срок против RTC, выводит производный ключ через HKDF и проверяет guest_sig над nonce.

RTC через slow memory ESP. Время выживает deep-sleep (RTC_DATA_ATTR). Хозяин на каждом fire-коннекте может скорректировать часы подписанной командой — для проверки TTL гостей точности секунд достаточно.

Pair-кнопка с factory reset. Короткое нажатие на непривязанном ESP — войти в pair-режим. Long-press 5 сек на привязанном — чистка NVS и заход в pair заново.

Если хочешь поучаствовать или поковыряться — скетч лежит, форкай и пиши.

Когда BLE не подходит

WiFi+интернет всегда есть на месте установки, и хочется управлять не только вблизи, а с любой точки мира — посмотрите WebSocket-вариант прошивки. Тот же контроллер на ESP32 или ESP8266, но устройство постоянно онлайн через WSS к серверу, открытие срабатывает откуда угодно с интернета.

Можно поставить и BLE и WS параллельно: BLE — для близкого срабатывания без интернета и батарейки, WS — для удалённого открытия гостям.

Не хочется ничего прошивать вообще — есть путь через веб-хук Entrixy в готовое облако или Tasmota/ESPHome устройства, см. /integrations.