/* * ============================================================================ * Entrixy BLE relay — эталонная прошивка * ============================================================================ * * Цель: автономное BLE-реле, к которому цепляется приложение Entrixy. * Поднял реле — машина/шлагбаум/замок открылся. * * Энергетика: * - Раз в 3 секунды просыпаемся из deep-sleep * - Поднимаем BLE, ~2с торчим в эфире, ждём connect * - Если клиент подключился — держим линк до disconnect или 8с * - Иначе сразу обратно в сон * - Средний ток ~100 мкА → AA-пара или CR123 живёт месяцами * * Безопасность (полная спецификация — см. https://entrixy.com/ble): * - ECDH X25519 при привязке — общий секрет не уходит в эфир * - HMAC-SHA256 на каждом advertise (anti-spoof) и каждом fire (anti-replay) * - Гостевые токены с TTL — выпускает хозяин через WebSocket-канал, * ESP про гостей не знает, только проверяет подпись хозяйским ключом * * Поддерживаемые чипы: ESP32, ESP32-C3, ESP32-S3 (различия в wakeup-API * и пинах разруливаются через #ifdef CONFIG_IDF_TARGET_*). * * Зависимости: * 1. Arduino core for ESP32 v3.0+ * 2. NimBLE-Arduino by h2zero (Library Manager → "NimBLE-Arduino"). * Используем явно, потому что в Arduino core 3.3+ дефолтный BLE-стек * сменили, и старый BLEDevice-API ломается на callback-сигнатурах — * onWrite молча не вызывается. С NimBLE-Arduino поведение детерминировано. * * Сборка в Arduino IDE 2.x: * Board: твоя плата (ESP32C3 Dev Module / ESP32S3 Dev Module / ...) * Partition: Default * Flash mode: QIO 80MHz * Erase Flash: первый раз "All Flash Contents", дальше "Only Sketch" * На S3 включи USB CDC On Boot — иначе Serial по USB-C не появится. * * Hardware (правь PIN_* константы под свою плату): * GPIO0 — pair-button (active-low, INPUT_PULLUP) * GPIO4 — relay (active-high) * GPIO5 — внешний LED «открыто» (горит во время relay-pulse) * статусный LED — внутренний на плате (GPIO8 для C3, GPIO48 для S3) * * ============================================================================ */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // WiFi/NTP — опциональный фоновой источник точного времени. Если ESP стоит в // зоне WiFi и хозяин записал креды в CHAR_WIFI, ESP периодически синхронизирует // часы через SNTP. Это снимает с гостей и хозяина бремя постоянно носить // "правильное время" — ESP сам знает. BLE-sync остаётся как fallback. #include #include // Опциональная поддержка внешнего I2C RTC-чипа (DS3231 / DS1307 / PCF8563). // Зачем: внутренний ESP RTC drift'ит ~5%/час на дешёвом RC-генераторе. При // длинных deep_sleep циклах часы уплывают на минуты в сутки. Внешний RTC с // CR2032 хранит время точно (DS3231: ±2ppm) годами даже при полном // отключении питания основной платы. // // EXT_RTC_TYPE: // EXT_RTC_NONE — нет внешнего RTC (по умолчанию). Опускается весь // Wire/RTClib код, скетч компилится без зависимостей. // EXT_RTC_DS3231 — DS3231 (рекомендуется: точный, температурная коррекция). // EXT_RTC_DS1307 — DS1307 (дешёвый, без температурной коррекции, ±20ppm). // EXT_RTC_PCF8563 — PCF8563 (часто на готовых модулях с ESP32). // // Все три используют I2C. Адрес чипа знает библиотека RTClib (Adafruit). // Установить через Library Manager: "RTClib by Adafruit". // // EXT_RTC_SDA / EXT_RTC_SCL — пины I2C. Стандарт для ESP32-S3 DevKit: 8/9. #define EXT_RTC_NONE 0 #define EXT_RTC_DS3231 1 #define EXT_RTC_DS1307 2 #define EXT_RTC_PCF8563 3 #define EXT_RTC_TYPE EXT_RTC_NONE #define EXT_RTC_SDA 8 #define EXT_RTC_SCL 9 #if EXT_RTC_TYPE != EXT_RTC_NONE #include #include #endif // ============================================================================= // PINS — поправь под свою плату // ============================================================================= constexpr uint8_t PIN_PAIR_BTN = 0; // кнопка pair / factory reset constexpr uint8_t PIN_RELAY = 4; // импульс на реле // Внешний LED-индикатор «открыто»: горит пока идёт relay-pulse, иначе погашен. // На плате юзера LED подключён активным низким уровнем (LED светится когда // пин LOW). Поэтому в fireRelay() пин гонится в LOW для зажигания, HIGH для // гашения — а pinMode при старте сразу HIGH (LED погашен). constexpr uint8_t PIN_OPEN_LED = 35; // Статусный LED — встроенный на DevKit-плате. На C3 это обычно GPIO8, // на S3 DevKitC-1 — встроенный RGB на GPIO48. #if CONFIG_IDF_TARGET_ESP32S3 constexpr uint8_t PIN_LED = 48; #else constexpr uint8_t PIN_LED = 8; #endif // ============================================================================= // Deep-sleep wakeup helper — на S3 нет ext0_wakeup, только ext1 // ============================================================================= static inline void enableWakeupOnButton() { #if CONFIG_IDF_TARGET_ESP32S3 // S3: ext1_wakeup с битовой маской RTC GPIO. GPIO 0..21 — RTC. esp_sleep_enable_ext1_wakeup((1ULL << PIN_PAIR_BTN), ESP_EXT1_WAKEUP_ANY_LOW); #else // C3 / обычный ESP32: ext0_wakeup на одном пине, уровень LOW. esp_sleep_enable_ext0_wakeup((gpio_num_t)PIN_PAIR_BTN, 0); #endif } // ============================================================================= // BLE UUIDs — публичная спецификация Entrixy // ============================================================================= // // Service UUID также используется как фильтр на стороне приложения для // Android passive BLE scan. Менять можно, но тогда нужно синхронно править // приложение (com/simfolder/dialer/ble/BlePair.kt, BleFire.kt). // // Characteristics: // NONCE — клиент читает 8-байтный одноразовый nonce // FIRE — клиент пишет подписанную fire-команду // TIME — хозяин корректирует RTC ESP (для проверки TTL гостевых токенов) // PAIR_PUB — клиент читает 32-байтный X25519-publickey ESP (pair-режим) // PAIR_DONE — клиент пишет [phone_pub 32B][device_id 4B LE] (pair-режим) static const char* SVC_UUID = "656e7472-7869-7900-0000-426c45000001"; static const char* CHAR_NONCE_UUID = "656e7472-7869-7900-0001-426c45000001"; static const char* CHAR_FIRE_UUID = "656e7472-7869-7900-0002-426c45000001"; static const char* CHAR_TIME_UUID = "656e7472-7869-7900-0003-426c45000001"; // CHAR_WIFI: только хозяин пишет туда подписанные SSID+password. ESP сохраняет // в NVS и пытается коннектиться → SNTP → ratchet auto-update. static const char* CHAR_WIFI_UUID = "656e7472-7869-7900-0004-426c45000001"; // CHAR_RESULT: NOTIFY-канал ответа. После каждого FIRE/TIME/WIFI ESP сетит // 1 байт кода и шлёт notify. Клиент подписан, видит вердикт — реле сработало, // бандл протух, время отвергнуто и т.д. Раньше клиент слепо считал успех // если GATT write прошёл — а ESP мог внутренне отвергнуть. static const char* CHAR_RESULT_UUID = "656e7472-7869-7900-0005-426c45000001"; static const char* CHAR_PAIR_PUB_UUID = "656e7472-7869-7900-00f0-426c45000001"; static const char* CHAR_PAIR_DONE_UUID = "656e7472-7869-7900-00f1-426c45000001"; // 16-bit company ID для manufacturer-specific data. Не зарегистрирован в // Bluetooth SIG, но это стабильный «магический» префикс — Android-парсер // кладёт его в ключ SparseArray, приложение фильтрует чужой эфир // этим значением. На wire в advertise идёт little-endian: 0xE0 0x00. constexpr uint16_t COMPANY_ID = 0x00E0; // ============================================================================= // Тайминги // ============================================================================= constexpr uint32_t WAKE_INTERVAL_MS = 3000; // полный цикл «advertise+sleep» constexpr uint32_t ADVERTISE_MS = 2000; // окно адверта — должно хватать // телефону на active-scan + GATT-connect constexpr uint32_t CONN_HOLD_MS = 8000; // если клиент подключился — // не уходим в сон до disconnect или CONN_HOLD_MS // 90 секунд — даём запас на медленные телефоны (Samsung A7 2017 и другие // старые модели), где BleActiveScanner.stop + scan-retry + handshake суммарно // уходят за 30 секунд. На быстрых телефонах пара проходит за 5-10с, лишнее // окно не мешает. constexpr uint32_t PAIR_WINDOW_MS = 90000; // ============================================================================= // Параметры реле — настраивай под свою железку // ============================================================================= // RELAY_PULSE_MS — длительность импульса на PIN_RELAY в миллисекундах. // Допустимо 50…60000 (до 1 минуты). Для электромагнитного замка обычно // 300-1000мс; для электромеханического или ригельного — больше (1-5с); // для удержания «открыто» долгое время — до 60с. // ВНИМАНИЕ: ESP не уходит в сон пока идёт импульс — длинные значения // съедают батарею при автономном питании. constexpr uint32_t RELAY_PULSE_MS = 5000; // RELAY_ACTIVE_HIGH — полярность активного состояния реле. // true → "открыто" = HIGH (PIN_RELAY подаёт +3.3V на impulse, idle = LOW) // false → "открыто" = LOW (PIN_RELAY подаёт GND на impulse, idle = HIGH) // Зависит от схемы модуля реле: оптопары и low-trigger релейные платы // открываются при LOW; прямые транзисторные ключи — при HIGH. constexpr bool RELAY_ACTIVE_HIGH = true; // LED_ACTIVE_HIGH — полярность встроенного статусного LED (PIN_LED). // true → светится при HIGH, гаснет при LOW (обычные DevKit-платы) // false → светится при LOW, гаснет при HIGH (некоторые клоны и платы // с инверсной схемой питания LED) // PIN_OPEN_LED (внешний «открыто») традиционно active-low — он подключён // между +3.3V и GPIO, поэтому LOW его зажигает. Для него отдельный флаг // OPEN_LED_ACTIVE_HIGH ниже. constexpr bool LED_ACTIVE_HIGH = true; constexpr bool OPEN_LED_ACTIVE_HIGH = false; // BISTABLE — режим привода (конфигуратор подставляет значение). // false (дефолт) — МОМЕНТ: fire даёт импульс RELAY_PULSE_MS на PIN_RELAY и // отпускает в idle. Для кнопок/электрозамков-defeat. // true — БИСТАБИЛЬНЫЙ замок: ДВА пина (открыть/закрыть). fire ПЕРЕКЛЮЧАЕТ // open↔closed и даёт импульс RELAY_PULSE_MS на соответствующий пин, // потом отпускает в уровень ВЫКЛ. Удержание в deep_sleep НЕ гарантируем // (импульс дал — спим); если привод требует постоянный уровень — это // вопрос схемы пользователя. constexpr bool BISTABLE = false; // Bistable: «ОТКРЫТЬ» = тот же PIN_RELAY с полярностью RELAY_ACTIVE_HIGH (как в // момент-режиме — это реле/катушка «открыть»). «ЗАКРЫТЬ» — отдельный PIN_CLOSE // со своими уровнями ВКЛ (импульс) / ВЫКЛ (покой), 1/0 независимо. constexpr uint8_t PIN_CLOSE = 5; constexpr uint8_t CLOSE_ON_LEVEL = 1; // импульс «закрыть»: уровень ВКЛ constexpr uint8_t CLOSE_OFF_LEVEL = 0; // уровень ВЫКЛ (покой) // SERIAL_DEBUG — отладочный вывод в UART. false → Serial.begin не вызывается, // UART0 не поднимается, и его пины (на ESP32-S3 это GPIO43/44) свободны под // реле открыть/закрыть. Без begin все Serial.print() — безопасные no-op'ы. constexpr bool SERIAL_DEBUG = true; constexpr uint32_t PAIR_LONG_PRESS_MS = 5000; // Производные значения idle/active для PIN_RELAY (используются в коде). #define RELAY_LEVEL_ACTIVE (RELAY_ACTIVE_HIGH ? HIGH : LOW) #define RELAY_LEVEL_IDLE (RELAY_ACTIVE_HIGH ? LOW : HIGH) #define LED_LEVEL_ON (LED_ACTIVE_HIGH ? HIGH : LOW) #define LED_LEVEL_OFF (LED_ACTIVE_HIGH ? LOW : HIGH) #define OPEN_LED_LEVEL_ON (OPEN_LED_ACTIVE_HIGH ? HIGH : LOW) #define OPEN_LED_LEVEL_OFF (OPEN_LED_ACTIVE_HIGH ? LOW : HIGH) // ============================================================================= // Persistent state (NVS) // ============================================================================= Preferences prefs; struct Config { uint8_t ownerSecret[32]; // общий секрет с хозяйским телефоном (ECDH) uint32_t ownerDeviceId; // 32-bit id, идёт в advertise uint32_t counter; // монотонный счётчик advertise, anti-replay bool paired; // флаг «есть пара» } cfg; // Положение бистабильного замка (только при BISTABLE=true). Грузится из NVS в // loadConfig, переживает reboot. true=open, false=closed. static bool g_lockOpen = false; // K_state — ключ для state-байта в advertise (open/closed). Выводится из // ownerSecret, выдаётся гостю в бандле (поле kstate), чтобы и гость мог читать // состояние из эфира. K_state = HKDF-SHA256(ikm=ownerSecret, info="ble-state-v1", // L=16). См. docs/DESIGN_OBJECT_STATE.md. Заполняется в loadConfig при paired. static uint8_t g_kState[16] = {0}; // ============================================================================= // RTC slow memory — переживает deep-sleep // ============================================================================= RTC_DATA_ATTR uint64_t g_rtcEpochMs = 0; RTC_DATA_ATTR bool g_rtcValid = false; // Счётчик wake-up'ов из deep_sleep — каждые N просыпаний делаем NTP-цикл. RTC_DATA_ATTR uint32_t g_wakeCount = 0; // Монотонный counter advertise через deep_sleep циклы. NVS пишется только на // pair-event'е, поэтому каждый wake loadConfig читает старое (обычно 0) и // counter растёт 1,2,3.. внутри окна, а следующий wake снова стартует с 0 → // Android дедупит одинаковые advertise и видит ESP редко. RTC slow memory // переживает deep_sleep (не переживает power-off — это ОК: cold-boot сбрасывает // и Android scan-сессию, и дедуп-кеш стэка). Без flash-write — RTC это SRAM, // не изнашивается. RTC_DATA_ATTR uint32_t g_persistedCounter = 0; RTC_DATA_ATTR bool g_persistedCounterValid = false; // На текущем wake — это «NTP-проход»? Выставляется в setup'е по g_wakeCount. static bool g_isNtpWake = false; // NTP/WiFi state. Объявлены здесь (а не в #if NTP_ENABLED секции ниже), чтобы // TimeCallback мог на них смотреть для проверки "NTP свежий → BLE-sync игнор". // Если NTP_ENABLED=0 — просто остаются нулями навсегда. static volatile uint32_t g_lastNtpSyncMs = 0; static volatile uint32_t g_lastWifiTryMs = 0; static volatile bool g_wifiConfigDirty = false; static String g_wifiSsid; static String g_wifiPass; // Монотонный ratchet для защиты от time-rollback атак. Сохраняется в NVS, // переживает не только deep-sleep, но и power-off / reset. Любой TIME-write // должен быть >= ratchet, иначе отвергается. Подробности — см. doc. uint64_t g_timeRatchetMs = 0; // Когда последний раз приняли TIME (uptime ms). 0 = ни разу с момента boot. static uint32_t g_lastSyncUptimeMs = 0; // Когда последний раз приняли FIRE (uptime ms). Для fire-first gate. static uint32_t g_lastFireUptimeMs = 0; // ============================================================================= // CONFIG: политика синхронизации времени // ============================================================================= // Тут крутить под конкретный объект. Хозяин (bleId=0) НИКОГДА не ограничен ни // по направлению, ни по величине прыжка — он доверенный, может выставить // сколько угодно вперёд или назад. Все ограничения ниже — только для гостей. // // FRESH_SYNC_WINDOW_MS: // "Окно свежести". Если последний принятый TIME был раньше этого окна, то // для нового гостевого TIME-write требуется чтобы тот же клиент за // FIRE_AUTH_WINDOW_MS перед этим успешно выстрелил FIRE. Это режет атаку // "у меня просрочен bundle, но я попробую тихонько подвинуть часы". // Малотрафиковая дача (раз в день кто-то приходит): 60 мин — норм. // Подъезд многоквартирного дома: ставь 5-10 мин — гости приходят часто, // нет смысла открывать широкое окно. static const uint32_t FRESH_SYNC_WINDOW_MS = 60UL * 60 * 1000; static const uint32_t FIRE_AUTH_WINDOW_MS = 10UL * 1000; // // TIME_GUEST_FWD_CAP_MS: // Сколько максимум гость может ПРОДВИНУТЬ часы вперёд за один write. // Гость не может двигать назад (ratchet режет всех кроме хозяина). // Тихий объект: 24ч ок — гости синкают редко, реальный дрейф телефонных // часов между приходами может быть значительным. // Высокотрафиковый объект: 5-15 мин — частые синки реальным временем // делают большие прыжки подозрительными, малый cap снижает урон от // потенциальной DoS-атаки drift'ом. static const uint64_t TIME_GUEST_FWD_CAP_MS = 24ULL * 3600 * 1000; // // NTP_ENABLED: // Если 1 — ESP пытается коннектиться к WiFi и синхронить SNTP. Креды // записывает хозяин через CHAR_WIFI (подписан owner_secret). При успешной // NTP-синхронизации часы и ratchet обновляются автоматически без участия // клиентов — гость может не носить "точное время с собой". BLE-sync // остаётся fallback'ом если объект вне WiFi или сеть упала. // Поставь 0 если плата не у WiFi и интернет недоступен — сэкономит ~50КБ // flash и убирает WiFi-стек целиком. #define NTP_ENABLED 0 // DEEP_SLEEP_ENABLED: // 0 — ESP всегда в эфире (питание от USB / БП, не экономим). Так удобно для // отладки, для домофона на 220В, для всего что подключено к интернету // через WiFi (NTP всё равно требует постоянного питания). // 1 — ESP просыпается раз в DEEP_SLEEP_WAKE_INTERVAL_S секунд, держит BLE // advertise ~DEEP_SLEEP_AWAKE_MS, потом снова в сон. Для AA-батареек. // НЕ совмещается с NTP_ENABLED=1 (WiFi не успевает поднять и // синхронизировать за короткое окно). #define DEEP_SLEEP_ENABLED 1 static const uint32_t DEEP_SLEEP_WAKE_INTERVAL_S = 10; static const uint32_t DEEP_SLEEP_AWAKE_MS = 1500; // Просыпание для NTP: раз в DEEP_SLEEP_NTP_EVERY_N_WAKES обычных wake-окон // просыпаемся "по-полному" — поднимаем WiFi, синхроним SNTP, потом обратно // в обычный цикл. На AA при 30000 wakes/мес (раз в 3с) = NTP раз в ~5ч // (100*3с=300с между NTP попытками... хм, 100 wakes = 300с = 5мин. Если // хочется раз в 6ч — ставь 7200.). // AWAKE_MS_NTP — сколько держать на NTP-цикле (WiFi connect + SNTP ~15с). static const uint32_t DEEP_SLEEP_NTP_EVERY_N_WAKES = 7200; // ~6ч при wake=3с static const uint32_t DEEP_SLEEP_AWAKE_MS_NTP = 20000; static const char* NTP_SERVER_1 = "pool.ntp.org"; static const char* NTP_SERVER_2 = "time.google.com"; static const uint32_t NTP_RESYNC_INTERVAL_MS = 6UL * 3600 * 1000; // раз в 6ч static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 15UL * 1000; static const uint32_t WIFI_RECONNECT_INTERVAL_MS = 5UL * 60 * 1000; // Идеи для будущих усилений (пока не реализовано): // - Time-of-day cap: ночью cap уже (никто не приходит → большой прыжок // подозрителен), днём шире. // - Multi-guest confirmation: ESP буферит несколько гостевых TIME proposals, // применяет только если N разных гостей предложили близкое значение // (взвешенное по timestamp). Защита от единичного злого guest-bundle. // - Per-guest throttle: один и тот же bleId не может писать TIME чаще // одного раза в TIME_GUEST_MIN_INTERVAL_MS. Сейчас throttle только // на стороне Android-клиента, что злоумышленник может игнорировать. // Кольцо последних выданных nonce. Samsung GATT в реальной жизни иногда // читает CHAR_NONCE дважды подряд перед одной записью FIRE — берёт первый // nonce в payload, а ESP хранил только последний. Получался mismatch и // FIRE rejected. Храним последние NONCE_RING_SIZE — принимаем любой из них, // после успешного FIRE инвалидируем всё кольцо (анти-replay). // 16 слотов = ~16 секунд при 1Hz advertise. Достаточно чтобы клиент успел // связаться даже если телефон видел advertise на границе окна. static const uint8_t NONCE_RING_SIZE = 16; // Помещаем в RTC slow memory — пёрживает deep_sleep. Иначе первое нажатие // гостя после wake'а получает "nonce mismatch": Android кэширует nonce из // предыдущего advertise, но ESP проснулся с пустым ring'ом. RTC_DATA_ATTR uint8_t nonceRing[NONCE_RING_SIZE][8]; RTC_DATA_ATTR bool nonceRingUsed[NONCE_RING_SIZE] = {0}; RTC_DATA_ATTR uint8_t nonceRingHead = 0; static void pushNonceToRing(const uint8_t* n) { memcpy(nonceRing[nonceRingHead], n, 8); nonceRingUsed[nonceRingHead] = true; nonceRingHead = (nonceRingHead + 1) % NONCE_RING_SIZE; } // Состояние GATT-сессии в work-mode. Без этого ESP уходил в deep_sleep ровно // тогда, когда телефон пытался установить connect, и FIRE не успевал дойти. // Счётчик (а не bool), потому что NimBLE допускает несколько одновременных // подключений: если второй клиент пришёл во время сессии первого, а первый // отвалится — bool сбросится в false и ESP уйдёт в сон с живым GATT-каналом. // Считаем onConnect++ / onDisconnect--, спим только при count==0. static volatile int g_workConnected = 0; static volatile uint32_t g_workConnectedAt = 0; // Время последней GATT-активности (read/write/connect). Используется для // hard-timeout: если клиент подключился но ничего не делает > STUCK_TIMEOUT_MS, // форсим disconnect, чтобы ESP мог уснуть. Иначе одна "залипшая" сессия держит // устройство в эфире и съедает батарею. static volatile uint32_t g_workLastActivity = 0; #define STUCK_CONN_TIMEOUT_MS 60000UL // Счётчики срабатываний GATT-callback'ов — печатаются из heartbeat'а главного // цикла. Полезны для отладки: если Android делает read/write, но эти счётчики // не растут — значит BLE-стек диспатчит ATT-запрос не в наш callback. static volatile uint32_t g_readCount = 0; static volatile uint32_t g_writeCount = 0; static volatile size_t g_lastWriteLen = 0; // Указатель на CHAR_RESULT для NOTIFY-ответов. Хранится глобально чтобы // колбэки FIRE/TIME/WIFI могли отправлять вердикт. Заполняется в createService // при build'е work-mode сервиса. NimBLECharacteristic* g_resultCh = nullptr; // Коды ответа в CHAR_RESULT (1 байт payload). Клиент маппит их в свои Outcome. // Группы: // 0x0x — FIRE-ответы // 0x1x — TIME-ответы // 0x2x — WIFI-ответы // 0x3x — FIRE-ответы с состоянием (BISTABLE): открыто/закрыто после переключения #define RES_FIRE_OK 0x00 #define RES_FIRE_OK_OPEN 0x30 // bistable: fire прошёл, теперь ОТКРЫТО #define RES_FIRE_OK_CLOSED 0x31 // bistable: fire прошёл, теперь ЗАКРЫТО #define RES_FIRE_NONCE_STALE 0x01 #define RES_FIRE_TTL_EXPIRED 0x02 #define RES_FIRE_OWNER_SIG_BAD 0x03 #define RES_FIRE_GUEST_SIG_BAD 0x04 #define RES_FIRE_BAD_LENGTH 0x05 #define RES_FIRE_HKDF_FAILED 0x06 #define RES_FIRE_OWNER_HMAC_BAD 0x07 #define RES_TIME_OK_OWNER 0x10 #define RES_TIME_OK_GUEST 0x11 #define RES_TIME_BAD_LENGTH 0x12 #define RES_TIME_HMAC_MISMATCH 0x13 #define RES_TIME_HKDF_FAILED 0x14 #define RES_TIME_ROLLBACK 0x15 #define RES_TIME_CAP_EXCEEDED 0x16 #define RES_TIME_FIRE_GATE 0x17 #define RES_TIME_NTP_AUTHORITY 0x18 #define RES_WIFI_OK 0x20 #define RES_WIFI_BAD_FORMAT 0x21 #define RES_WIFI_HMAC_MISMATCH 0x22 static void sendResult(uint8_t code) { if (!g_resultCh) return; g_resultCh->setValue(&code, 1); g_resultCh->notify(); Serial.printf("[ble] RESULT notify code=0x%02x\n", code); } class WorkConnCallback : public NimBLEServerCallbacks { void onConnect(NimBLEServer*, NimBLEConnInfo& info) override { g_workConnected++; g_workConnectedAt = millis(); g_workLastActivity = millis(); Serial.printf("[ble] client connected — addr=%s handle=%u count=%d\n", info.getAddress().toString().c_str(), info.getConnHandle(), (int)g_workConnected); } void onDisconnect(NimBLEServer*, NimBLEConnInfo& info, int reason) override { if (g_workConnected > 0) g_workConnected--; Serial.printf("[ble] client disconnected, addr=%s reason=%d count=%d\n", info.getAddress().toString().c_str(), reason, (int)g_workConnected); } void onMTUChange(uint16_t mtu, NimBLEConnInfo& info) override { Serial.printf("[ble] MTU change to %u (handle=%u)\n", mtu, info.getConnHandle()); } }; // ============================================================================= // Logging helpers // ============================================================================= static void logHex(const char* label, const uint8_t* data, size_t len) { Serial.printf("[hex] %s (%u bytes):", label, (unsigned)len); for (size_t i = 0; i < len; i++) { if (i % 16 == 0) Serial.print(" "); Serial.printf("%02x", data[i]); } Serial.println(); } // ============================================================================= // HMAC-SHA256 / HKDF // ============================================================================= static void hmacSha256(const uint8_t* key, size_t keyLen, const uint8_t* msg, size_t msgLen, uint8_t* out32) { mbedtls_md_context_t ctx; mbedtls_md_init(&ctx); mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1); mbedtls_md_hmac_starts(&ctx, key, keyLen); mbedtls_md_hmac_update(&ctx, msg, msgLen); mbedtls_md_hmac_finish(&ctx, out32); mbedtls_md_free(&ctx); } static int hkdfSha256(const uint8_t* salt, size_t saltLen, const uint8_t* ikm, size_t ikmLen, const uint8_t* info, size_t infoLen, uint8_t* out, size_t outLen) { return mbedtls_hkdf(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), salt, saltLen, ikm, ikmLen, info, infoLen, out, outLen); } // ============================================================================= // RTC helpers // ============================================================================= // Базовое epoch_ms на старте текущего awake-окна. millis() добавляется к нему // чтобы получить "сейчас". Хранится в RTC_DATA_ATTR — переживает deep_sleep. // На sleep_start обновляется на (текущее_сейчас + сон). RTC_DATA_ATTR uint64_t g_baseEpochMs = 0; static uint64_t epochMsNow() { // Свой счётчик: gettimeofday/RTC во время awake не тикает у нас (баг ESP-IDF // в связке с deep_sleep), поэтому считаем сами через millis() от base. return g_baseEpochMs + (uint64_t)millis(); } static void rtcRestore() { if (g_rtcValid && g_baseEpochMs > 0) { struct timeval tv; tv.tv_sec = g_baseEpochMs / 1000; tv.tv_usec = (g_baseEpochMs % 1000) * 1000; settimeofday(&tv, nullptr); Serial.printf("[rtc] restored epoch_ms=%llu\n", (unsigned long long)g_baseEpochMs); } } static void rtcSave() { // Перед сном фиксируем "что часы покажут после следующего wake". // base += awake_elapsed + sleep_duration. На wake это base уже актуально. if (g_rtcValid) { g_baseEpochMs = g_baseEpochMs + (uint64_t)millis(); #if DEEP_SLEEP_ENABLED g_baseEpochMs += (uint64_t)DEEP_SLEEP_WAKE_INTERVAL_S * 1000ULL; #endif g_rtcEpochMs = g_baseEpochMs; } } // ============================================================================= // Внешний I2C RTC (DS3231 / DS1307 / PCF8563) // ============================================================================= // API одинаковое во всех 3 случаях: extRtcInit/Read/Write. При EXT_RTC_NONE // функции — пустышки, ничего не делают, ничего не весят. #if EXT_RTC_TYPE == EXT_RTC_DS3231 static RTC_DS3231 g_extRtc; #elif EXT_RTC_TYPE == EXT_RTC_DS1307 static RTC_DS1307 g_extRtc; #elif EXT_RTC_TYPE == EXT_RTC_PCF8563 static RTC_PCF8563 g_extRtc; #endif #if EXT_RTC_TYPE != EXT_RTC_NONE static bool g_extRtcReady = false; #endif static bool extRtcInit() { #if EXT_RTC_TYPE == EXT_RTC_NONE return false; #else // I2C bus. Wire.begin принимает SDA, SCL для ESP32. Wire.begin(EXT_RTC_SDA, EXT_RTC_SCL); // begin() возвращает true если чип ответил по I2C. Без чипа — false, // тогда просто продолжаем без внешнего RTC, не валим setup. if (!g_extRtc.begin()) { Serial.println("[rtc-ext] begin() failed — chip not responding on I2C"); g_extRtcReady = false; return false; } g_extRtcReady = true; Serial.println("[rtc-ext] OK"); return true; #endif } // Прочитать epoch_ms с внешнего RTC. 0 = чип не отвечает или время невалидное // (свежий с завода имеет 2000-01-01). Авторитетным считается только год≥2024. static uint64_t extRtcReadMs() { #if EXT_RTC_TYPE == EXT_RTC_NONE return 0; #else if (!g_extRtcReady) return 0; DateTime now = g_extRtc.now(); if (!now.isValid() || now.year() < 2024) { Serial.printf("[rtc-ext] invalid time on chip (year=%u)\n", now.year()); return 0; } return (uint64_t)now.unixtime() * 1000ULL; #endif } // Записать epoch_ms в внешний RTC. Идёт от owner-TIME и от NTP — гости в чип // не пишут (только в NVS ratchet). static void extRtcWriteMs(uint64_t epochMs) { #if EXT_RTC_TYPE == EXT_RTC_NONE (void)epochMs; return; #else if (!g_extRtcReady) return; uint32_t epochS = (uint32_t)(epochMs / 1000ULL); g_extRtc.adjust(DateTime(epochS)); Serial.printf("[rtc-ext] written epoch=%u\n", (unsigned)epochS); #endif } // ============================================================================= // NVS load/save/wipe // ============================================================================= static void loadConfig(bool fromDeepSleep = false) { prefs.begin("entrixy", true); size_t got = prefs.getBytes("secret", cfg.ownerSecret, 32); cfg.paired = (got == 32); cfg.ownerDeviceId = prefs.getUInt("did", 0); cfg.counter = prefs.getUInt("ctr", 0); g_timeRatchetMs = prefs.getULong64("ratchet", 0); g_lockOpen = prefs.getBool("lockpos", false); // bistable: положение замка prefs.end(); // На wake из deep_sleep counter в NVS устарел (пишется только при pair). // Берём монотонное значение из RTC, чтобы advertise не повторялся и Android // не дедупил. На cold-boot g_persistedCounterValid=false → стартуем с NVS. if (fromDeepSleep && g_persistedCounterValid) { cfg.counter = g_persistedCounter; } Serial.printf("[nvs] load: paired=%d did=0x%08x counter=%u secret_bytes=%u ratchet=%llu\n", (int)cfg.paired, cfg.ownerDeviceId, cfg.counter, (unsigned)got, (unsigned long long)g_timeRatchetMs); if (cfg.paired) { logHex("nvs:secret", cfg.ownerSecret, 32); // K_state для state-байта в advertise (см. DESIGN_OBJECT_STATE.md). const char* ksInfo = "ble-state-v1"; hkdfSha256(nullptr, 0, cfg.ownerSecret, 32, (const uint8_t*)ksInfo, strlen(ksInfo), g_kState, 16); } // Если есть ratchet > 0 — это последнее достоверное время. // На холодном старте — настраиваем системные часы по нему. На wake из // deep_sleep — НЕ трогаем (системные часы уже корректно продолжили // считать через RTC slow clock, наш rachet старый и затрёт прогресс). if (g_timeRatchetMs > 0 && !fromDeepSleep) { // Холодный старт: base = ratchet, millis() будет добавляться сверху. g_baseEpochMs = g_timeRatchetMs; struct timeval tv; tv.tv_sec = g_baseEpochMs / 1000; tv.tv_usec = (g_baseEpochMs % 1000) * 1000; settimeofday(&tv, nullptr); g_rtcValid = true; g_rtcEpochMs = g_baseEpochMs; Serial.printf("[rtc] cold-boot from NVS ratchet=%llu\n", (unsigned long long)g_timeRatchetMs); } else if (g_timeRatchetMs > 0 && fromDeepSleep) { // Wake-from-sleep: g_baseEpochMs уже обновлён в предыдущем rtcSave() // на величину (прошлый_awake + сон). Просто помечаем валидным. g_rtcValid = true; Serial.printf("[rtc] wake-from-sleep base=%llu epoch_now=%llu\n", (unsigned long long)g_baseEpochMs, (unsigned long long)epochMsNow()); } } static void saveRatchet() { prefs.begin("entrixy", false); prefs.putULong64("ratchet", g_timeRatchetMs); prefs.end(); } static void saveConfig() { prefs.begin("entrixy", false); size_t w1 = prefs.putBytes("secret", cfg.ownerSecret, 32); bool w2 = prefs.putUInt("did", cfg.ownerDeviceId) > 0; bool w3 = prefs.putUInt("ctr", cfg.counter) > 0; prefs.end(); Serial.printf("[nvs] save: secret_written=%u did_ok=%d ctr_ok=%d\n", (unsigned)w1, (int)w2, (int)w3); } static void wipeConfig() { prefs.begin("entrixy", false); prefs.clear(); prefs.end(); cfg.paired = false; cfg.counter = 0; memset(cfg.ownerSecret, 0, 32); Serial.println("[nvs] wiped"); } // ============================================================================= // Relay // ============================================================================= // Запрос на pulse от GATT-callback. Сам pulse делается из loop(), чтобы // не блокировать ответ NimBLE на FIRE write на 500мс — клиент должен // получить ack как можно быстрее (раньше реального срабатывания реле). static volatile bool g_pulseRequest = false; static volatile uint8_t g_pulsePin = PIN_RELAY; // какой пин импульсим static volatile int g_pulseOn = HIGH; // уровень ВКЛ static volatile int g_pulseOff = LOW; // уровень ВЫКЛ (покой после импульса) static volatile uint32_t g_pulseStartedAt = 0; static volatile bool g_pulseActive = false; // Момент-режим: импульс на PIN_RELAY (ВКЛ = active, ВЫКЛ = idle). static void triggerRelay() { g_pulsePin = PIN_RELAY; g_pulseOn = RELAY_LEVEL_ACTIVE; g_pulseOff = RELAY_LEVEL_IDLE; g_pulseRequest = true; } // Bistable: импульс на заданный пин со своими уровнями ВКЛ/ВЫКЛ. static void triggerPulse(uint8_t pin, int onLevel, int offLevel) { g_pulsePin = pin; g_pulseOn = onLevel; g_pulseOff = offLevel; g_pulseRequest = true; } // ── BISTABLE: сохранить положение замка в NVS (переживёт reboot) ── static void saveLockState() { prefs.begin("entrixy", false); prefs.putBool("lockpos", g_lockOpen); prefs.end(); } static void servicePulse() { if (g_pulseRequest && !g_pulseActive) { Serial.printf("[relay] PULSE START (pin=%u on=%d off=%d, %u ms)\n", (unsigned)g_pulsePin, g_pulseOn, g_pulseOff, (unsigned)RELAY_PULSE_MS); digitalWrite(g_pulsePin, g_pulseOn); digitalWrite(PIN_LED, LED_LEVEL_ON); digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_ON); g_pulseStartedAt = millis(); g_pulseActive = true; g_pulseRequest = false; } if (g_pulseActive && (millis() - g_pulseStartedAt) >= RELAY_PULSE_MS) { digitalWrite(g_pulsePin, g_pulseOff); digitalWrite(PIN_LED, LED_LEVEL_OFF); digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_OFF); g_pulseActive = false; Serial.println("[relay] PULSE END"); } } // ============================================================================= // Owner fire verify (24 байта) // ============================================================================= static int findNonceInRing(const uint8_t* candidate) { for (int i = 0; i < NONCE_RING_SIZE; i++) { if (nonceRingUsed[i] && memcmp(nonceRing[i], candidate, 8) == 0) return i; } return -1; } static void clearNonceRing() { for (int i = 0; i < NONCE_RING_SIZE; i++) nonceRingUsed[i] = false; } // Инвалидация конкретного слота — для анти-replay одного использованного // nonce'а. Остальные слоты остаются валидными, чтобы клиент мог быстро // слать второй fire с другим (свежим) advertise-nonce без race. static void invalidateNonceSlot(int idx) { if (idx >= 0 && idx < NONCE_RING_SIZE) nonceRingUsed[idx] = false; } static bool anyNonceIssued() { for (int i = 0; i < NONCE_RING_SIZE; i++) if (nonceRingUsed[i]) return true; return false; } static int g_lastMatchedSlot = -1; // запоминаем какой слот ринга сработал static uint8_t verifyOwnerFire(const uint8_t* p, size_t n) { bool issued = anyNonceIssued(); Serial.printf("[fire-owner] verify: len=%u nonceIssued=%d\n", (unsigned)n, (int)issued); if (n != 24) return RES_FIRE_BAD_LENGTH; if (!issued) return RES_FIRE_NONCE_STALE; int idx = findNonceInRing(p); if (idx < 0) { Serial.println("[fire-owner] nonce mismatch (not in ring)"); logHex("got ", p, 8); return RES_FIRE_NONCE_STALE; } g_lastMatchedSlot = idx; uint8_t mac[32]; hmacSha256(cfg.ownerSecret, 32, p, 8, mac); bool ok = memcmp(mac, p + 8, 16) == 0; Serial.printf("[fire-owner] HMAC %s\n", ok ? "OK" : "MISMATCH"); if (!ok) { logHex("expected mac[0..15]", mac, 16); logHex("got mac ", p + 8, 16); return RES_FIRE_OWNER_HMAC_BAD; } return RES_FIRE_OK; } // ============================================================================= // Guest fire verify (47 байт) // ============================================================================= static uint8_t verifyGuestFire(const uint8_t* p, size_t n) { Serial.printf("[fire-guest] verify: len=%u nonceIssued=%d\n", (unsigned)n, (int)anyNonceIssued()); bool issued = anyNonceIssued(); if (n != 47) return RES_FIRE_BAD_LENGTH; if (!issued) return RES_FIRE_NONCE_STALE; const uint8_t* token = p + 0; const uint8_t* ownerSigPtr = p + 7; const uint8_t* noncePtr = p + 23; const uint8_t* guestSigPtr = p + 31; int gidx = findNonceInRing(noncePtr); if (gidx < 0) { Serial.println("[fire-guest] nonce mismatch (not in ring)"); return RES_FIRE_NONCE_STALE; } g_lastMatchedSlot = gidx; uint8_t macOwner[32]; hmacSha256(cfg.ownerSecret, 32, token, 7, macOwner); if (memcmp(macOwner, ownerSigPtr, 16) != 0) { Serial.println("[fire-guest] owner_sig mismatch"); return RES_FIRE_OWNER_SIG_BAD; } uint32_t validUntil = (uint32_t)token[2] | ((uint32_t)token[3] << 8) | ((uint32_t)token[4] << 16) | ((uint32_t)token[5] << 24); time_t now = time(nullptr); Serial.printf("[fire-guest] valid_until=%u now=%lld rtc_valid=%d\n", (unsigned)validUntil, (long long)now, (int)g_rtcValid); if (!g_rtcValid || now > (time_t)validUntil) { Serial.println("[fire-guest] TTL expired"); return RES_FIRE_TTL_EXPIRED; } uint8_t info[2 + 5]; memcpy(info, "guest", 5); info[5] = token[0]; info[6] = token[1]; uint8_t guestKey[32]; if (hkdfSha256(nullptr, 0, cfg.ownerSecret, 32, info, sizeof(info), guestKey, 32) != 0) { Serial.println("[fire-guest] HKDF failed"); return RES_FIRE_HKDF_FAILED; } uint8_t macGuest[32]; hmacSha256(guestKey, 32, noncePtr, 8, macGuest); bool ok = memcmp(macGuest, guestSigPtr, 16) == 0; Serial.printf("[fire-guest] guest_sig %s\n", ok ? "OK" : "MISMATCH"); return ok ? RES_FIRE_OK : RES_FIRE_GUEST_SIG_BAD; } // ============================================================================= // GATT callbacks // ============================================================================= class FireCallback : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) override { g_writeCount++; g_workLastActivity = millis(); std::string val = ch->getValue(); g_lastWriteLen = val.length(); Serial.printf("[ble] FIRE write: len=%u\n", val.length()); const uint8_t* p = (const uint8_t*)val.c_str(); uint8_t code = RES_FIRE_BAD_LENGTH; if (val.length() == 24) code = verifyOwnerFire(p, val.length()); else if (val.length() == 47) code = verifyGuestFire(p, val.length()); else Serial.printf("[ble] FIRE unknown length %u (expected 24 or 47)\n", val.length()); if (code == RES_FIRE_OK) { if (BISTABLE) { // Бистабильный привод: каждый fire ПЕРЕКЛЮЧАЕТ положение и даёт импульс // RELAY_PULSE_MS на нужный пин (открыть → PIN_RELAY, закрыть → PIN_CLOSE), // потом пин уходит в свой уровень ВЫКЛ. Логическое состояние в NVS. g_lockOpen = !g_lockOpen; saveLockState(); if (g_lockOpen) triggerRelay(); // открыть = PIN_RELAY (active→idle) else triggerPulse(PIN_CLOSE, CLOSE_ON_LEVEL, CLOSE_OFF_LEVEL); Serial.printf("[relay] BISTABLE pulse → %s (pin=%u)\n", g_lockOpen ? "OPEN" : "CLOSED", (unsigned)(g_lockOpen ? PIN_RELAY : PIN_CLOSE)); // Возвращаем клиенту КОНКРЕТНОЕ состояние — приложение покажет // «Открыто»/«Закрыто» вместо «Сработало». code = g_lockOpen ? RES_FIRE_OK_OPEN : RES_FIRE_OK_CLOSED; } else { triggerRelay(); // момент-режим: импульс на PIN_RELAY } // Анти-replay только для использованного nonce. Остальные свежие // advertise-nonce'ы в ring остаются валидными — клиент может // спокойно слать следующий fire с другим nonce. invalidateNonceSlot(g_lastMatchedSlot); g_lastMatchedSlot = -1; // Помечаем момент успешного fire — TimeCallback использует это для // fire-first gate в свежем sync-окне. g_lastFireUptimeMs = millis(); } else { Serial.printf("[ble] FIRE rejected code=0x%02x\n", code); } sendResult(code); } }; class NonceCallback : public NimBLECharacteristicCallbacks { void onRead(NimBLECharacteristic* ch, NimBLEConnInfo&) override { g_readCount++; g_workLastActivity = millis(); // Старый путь: клиент читает CHAR_NONCE и пишет fire с этим nonce. // Оставлено для совместимости со старыми клиентами. Новые клиенты // (≥5.54) берут nonce прямо из advertise и не делают READ вообще. uint8_t fresh[8]; esp_fill_random(fresh, 8); pushNonceToRing(fresh); ch->setValue(fresh, 8); logHex("nonce-issued (read)", fresh, 8); } }; class TimeCallback : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) override { g_workLastActivity = millis(); std::string val = ch->getValue(); Serial.printf("[ble] TIME write: len=%u\n", val.length()); // Старый формат (24 байта = epochMs[8] + HMAC[16] под owner_secret) больше // не принимаем — все клиенты обновлены на 26-байтный формат с bleId. // Это упрощает callback и исключает амбигуити "это owner или guest?". if (val.length() != 26) { Serial.println("[ble] TIME wrong length (expected 26)"); sendResult(RES_TIME_BAD_LENGTH); return; } const uint8_t* p = (const uint8_t*)val.c_str(); // Layout: bleId[2] + epochMs[8] + HMAC[16] uint16_t bleId = (uint16_t)p[0] | ((uint16_t)p[1] << 8); uint64_t epochMs = 0; for (int i = 0; i < 8; i++) epochMs |= (uint64_t)p[2 + i] << (8 * i); // Шаг 1: подпись. bleId=0 → owner; иначе деривируем guest_key. bool isOwner = (bleId == 0); uint8_t key[32]; if (isOwner) { memcpy(key, cfg.ownerSecret, 32); } else { uint8_t info[2 + 5]; memcpy(info, "guest", 5); info[5] = (uint8_t)(bleId & 0xff); info[6] = (uint8_t)((bleId >> 8) & 0xff); if (hkdfSha256(nullptr, 0, cfg.ownerSecret, 32, info, sizeof(info), key, 32) != 0) { Serial.println("[ble] TIME HKDF failed"); sendResult(RES_TIME_HKDF_FAILED); return; } } uint8_t mac[32]; hmacSha256(key, 32, p, 10, mac); if (memcmp(mac, p + 10, 16) != 0) { Serial.printf("[ble] TIME HMAC mismatch (bleId=%u isOwner=%d) — rejected\n", (unsigned)bleId, (int)isOwner); sendResult(RES_TIME_HMAC_MISMATCH); return; } uint32_t nowUp = millis(); #if NTP_ENABLED // Если NTP-sync прошёл недавно — ESP считает что у него АБСОЛЮТНО точное // время и BLE-time-sync ему не нужен. Гостевые TIME-writes молча // игнорируем (NTP — авторитет). Хозяин всё равно может перезаписать — // на случай "WiFi умер, надо сдвинуть руками". if (!isOwner && g_lastNtpSyncMs > 0 && (nowUp - g_lastNtpSyncMs) < NTP_RESYNC_INTERVAL_MS * 2) { Serial.println("[ble] TIME ignored — NTP authoritative"); sendResult(RES_TIME_NTP_AUTHORITY); return; } #endif // Шаги 2-4 — только для гостей. Хозяин (isOwner=true) обходит все // ограничения: может выставить любое время, в любую сторону, без gate. // Это нужно чтобы можно было откатить ESP если, например, какой-то // клиент по ошибке выставил время в 2099. if (!isOwner) { // Шаг 2: ratchet — гость не может откатить время назад. if (epochMs < g_timeRatchetMs) { Serial.printf("[ble] TIME rollback rejected (guest): new=%llu < ratchet=%llu\n", (unsigned long long)epochMs, (unsigned long long)g_timeRatchetMs); sendResult(RES_TIME_ROLLBACK); return; } // Шаг 3: cap — гость может прыгнуть вперёд максимум на CAP за раз. if (g_timeRatchetMs > 0 && epochMs - g_timeRatchetMs > TIME_GUEST_FWD_CAP_MS) { Serial.printf("[ble] TIME guest forward-cap exceeded: delta=%llu ms\n", (unsigned long long)(epochMs - g_timeRatchetMs)); sendResult(RES_TIME_CAP_EXCEEDED); return; } // Шаг 4: fire-first gate. Если совсем недавно (< FRESH_SYNC_WINDOW) // принимали TIME — не позволяем кому попало двигать часы. Требуем // что клиент успешно отстрелял FIRE за последние FIRE_AUTH_WINDOW. // Если sync был давно (или ни разу после boot) — gate пропускаем, // гость может бутстрапнуть часы по advertise. bool freshSync = (g_lastSyncUptimeMs > 0) && ((nowUp - g_lastSyncUptimeMs) < FRESH_SYNC_WINDOW_MS); if (freshSync) { bool recentFire = (g_lastFireUptimeMs > 0) && ((nowUp - g_lastFireUptimeMs) < FIRE_AUTH_WINDOW_MS); if (!recentFire) { Serial.printf("[ble] TIME in fresh-window without recent FIRE — rejected " "(sinceSync=%u ms, sinceFire=%u ms)\n", nowUp - g_lastSyncUptimeMs, g_lastFireUptimeMs ? nowUp - g_lastFireUptimeMs : 0); sendResult(RES_TIME_FIRE_GATE); return; } } } // Шаг 5: всё ок — применяем. struct timeval tv; tv.tv_sec = epochMs / 1000; tv.tv_usec = (epochMs % 1000) * 1000; settimeofday(&tv, nullptr); g_rtcValid = true; g_rtcEpochMs = epochMs; g_timeRatchetMs = epochMs; g_lastSyncUptimeMs = nowUp; saveRatchet(); // Записываем в внешний RTC только если это хозяин (доверенный источник). // Гость может вилять временем в пределах cap'а, но в hardware-чип его не // пускаем — там должна быть только проверенная истина. if (isOwner) { extRtcWriteMs(epochMs); } Serial.printf("[ble] TIME accepted: epoch_ms=%llu bleId=%u isOwner=%d\n", (unsigned long long)epochMs, (unsigned)bleId, (int)isOwner); sendResult(isOwner ? RES_TIME_OK_OWNER : RES_TIME_OK_GUEST); } }; #if NTP_ENABLED // ============================================================================= // WiFi/NTP — опциональный источник точного времени // ============================================================================= // Записывается ТОЛЬКО хозяином, подписан owner_secret. Payload: // [ssid_len 1B][ssid N B][pass_len 1B][pass M B][HMAC 16B] // HMAC поверх (ssid_len + ssid + pass_len + pass). После приёма ESP сохраняет // в NVS и пытается коннектиться. При успехе SNTP делает первичную sync; // дальше — раз в NTP_RESYNC_INTERVAL_MS из основного loop'а. // // Для отладки можно зашить дефолтные креды через WIFI_DEFAULT_SSID/PASS. // Если NVS пуст и дефолты не пустые — используются дефолты. Если NVS // заполнен — он приоритетней. Чтобы сбросить на дефолты, нужен factory-reset. #define WIFI_DEFAULT_SSID "SIM" #define WIFI_DEFAULT_PASS "roulette" static void loadWifiCreds() { prefs.begin("entrixy", true); g_wifiSsid = prefs.getString("ssid", ""); g_wifiPass = prefs.getString("pass", ""); prefs.end(); if (g_wifiSsid.length() == 0 && strlen(WIFI_DEFAULT_SSID) > 0) { g_wifiSsid = WIFI_DEFAULT_SSID; g_wifiPass = WIFI_DEFAULT_PASS; Serial.printf("[wifi] using default creds (ssid='%s')\n", g_wifiSsid.c_str()); } else { Serial.printf("[wifi] loaded from NVS: ssid='%s' pass_len=%u\n", g_wifiSsid.c_str(), (unsigned)g_wifiPass.length()); } } static void saveWifiCreds(const String& ssid, const String& pass) { prefs.begin("entrixy", false); prefs.putString("ssid", ssid); prefs.putString("pass", pass); prefs.end(); } class WifiCallback : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) override { g_workLastActivity = millis(); std::string val = ch->getValue(); Serial.printf("[ble] WIFI write: len=%u\n", val.length()); const uint8_t* p = (const uint8_t*)val.c_str(); if (val.length() < 18) { Serial.println("[ble] WIFI too short"); sendResult(RES_WIFI_BAD_FORMAT); return; } uint8_t ssidLen = p[0]; if ((size_t)1 + ssidLen + 1 > val.length()) { Serial.println("[ble] WIFI bad ssid_len"); sendResult(RES_WIFI_BAD_FORMAT); return; } uint8_t passLen = p[1 + ssidLen]; size_t bodyLen = 1 + ssidLen + 1 + passLen; if (bodyLen + 16 != val.length()) { Serial.println("[ble] WIFI bad framing"); sendResult(RES_WIFI_BAD_FORMAT); return; } uint8_t mac[32]; hmacSha256(cfg.ownerSecret, 32, p, bodyLen, mac); if (memcmp(mac, p + bodyLen, 16) != 0) { Serial.println("[ble] WIFI HMAC mismatch"); sendResult(RES_WIFI_HMAC_MISMATCH); return; } String ssid((const char*)(p + 1), ssidLen); String pass((const char*)(p + 1 + ssidLen + 1), passLen); saveWifiCreds(ssid, pass); g_wifiSsid = ssid; g_wifiPass = pass; g_wifiConfigDirty = true; Serial.printf("[wifi] creds saved: ssid='%s' pass_len=%u\n", ssid.c_str(), (unsigned)pass.length()); sendResult(RES_WIFI_OK); } }; static void wifiTryConnect() { if (g_wifiSsid.length() == 0) return; Serial.printf("[wifi] connecting to '%s'\n", g_wifiSsid.c_str()); WiFi.mode(WIFI_STA); WiFi.begin(g_wifiSsid.c_str(), g_wifiPass.c_str()); uint32_t start = millis(); while (WiFi.status() != WL_CONNECTED && (millis() - start) < WIFI_CONNECT_TIMEOUT_MS) { delay(200); } if (WiFi.status() != WL_CONNECTED) { Serial.printf("[wifi] connect FAILED status=%d\n", (int)WiFi.status()); WiFi.disconnect(true); return; } Serial.printf("[wifi] connected ip=%s rssi=%d\n", WiFi.localIP().toString().c_str(), WiFi.RSSI()); // SNTP запускаем один раз. configTime в Arduino-core настраивает sntp. configTime(0, 0, NTP_SERVER_1, NTP_SERVER_2, "time.cloudflare.com"); // Ждём первую sync до 10 секунд. uint32_t ntpStart = millis(); while ((millis() - ntpStart) < 10000) { time_t now = time(nullptr); if (now > 1700000000) { // > 2023-11-14 → SNTP отработал uint64_t epochMs = (uint64_t)now * 1000ULL; struct timeval tv = { .tv_sec = now, .tv_usec = 0 }; settimeofday(&tv, nullptr); g_rtcValid = true; g_rtcEpochMs = epochMs; // NTP считаем абсолютным источником: ratchet всегда переписывается. // В отличие от гостевого BLE-write, тут нет cap'ов и fire-gate. g_timeRatchetMs = epochMs; g_lastNtpSyncMs = millis(); saveRatchet(); extRtcWriteMs(epochMs); Serial.printf("[ntp] sync OK epoch=%lld\n", (long long)now); return; } delay(500); } Serial.println("[ntp] sync TIMEOUT"); } static void wifiNtpService() { if (g_wifiSsid.length() == 0) return; uint32_t now = millis(); // Только что записали новые креды → переподключиться немедленно. if (g_wifiConfigDirty) { g_wifiConfigDirty = false; g_lastWifiTryMs = now; wifiTryConnect(); return; } // WiFi не поднят и пора попробовать снова. if (WiFi.status() != WL_CONNECTED) { if (g_lastWifiTryMs == 0 || (now - g_lastWifiTryMs) >= WIFI_RECONNECT_INTERVAL_MS) { g_lastWifiTryMs = now; wifiTryConnect(); } return; } // WiFi подключён, пора re-NTP? if (g_lastNtpSyncMs == 0 || (now - g_lastNtpSyncMs) >= NTP_RESYNC_INTERVAL_MS) { time_t t = time(nullptr); if (t > 1700000000) { uint64_t epochMs = (uint64_t)t * 1000ULL; g_rtcValid = true; g_rtcEpochMs = epochMs; g_timeRatchetMs = epochMs; g_lastNtpSyncMs = now; saveRatchet(); extRtcWriteMs(epochMs); Serial.printf("[ntp] periodic sync epoch=%lld\n", (long long)t); } } } #endif // NTP_ENABLED // ============================================================================= // Advertise build // ============================================================================= // Версия прошивки — мажор/минор. Шлётся в advertise (зашифрованный байт), // owner видит на карточке. Менять при каждом feature-релизе. #define FW_VERSION_MAJOR 1 #define FW_VERSION_MINOR 1 // Тип железа — enum, для UI-подсказок. Поставь подходящее перед прошивкой. // 0 = unknown / generic // 1 = ESP32-C3 DevKit // 2 = ESP32-S3 DevKit // 3 = ESP32-C6 // 4-255 — кастомные сборки #define HW_MODEL 2 // Опциональный пин ADC для измерения напряжения батареи. -1 = не измеряем // (devboard на USB-питании). Подключи делитель 100k/100k между VBAT и GND, // середина на этот пин — получишь V/2 (для AA × 2 = 1.5V max после делителя). #define PIN_BATTERY_ADC -1 // Status flag bits #define STATUS_BIT_RELAY_ACTIVE 0x01 #define STATUS_BIT_LOW_BATTERY 0x02 #define STATUS_BIT_TIME_SYNCED 0x04 #define STATUS_BIT_TAMPER 0x08 #define STATUS_BIT_NTP_OK 0x10 static uint8_t readBatteryPercent() { #if (PIN_BATTERY_ADC) >= 0 // Простое приближение: 3 измерения, усреднение, маппинг на 0-100%. // Линейная аппроксимация ниже только под AA × 2 с резисторным делителем 1:2. // Для Li-Ion / LiPo нужна другая кривая (3.0-4.2V → 0-100%). uint32_t sum = 0; for (int i = 0; i < 3; i++) sum += analogRead((uint8_t)PIN_BATTERY_ADC); uint32_t avg = sum / 3; // analogRead вернёт 0-4095 на ESP32. 12-bit, ref ~3.1V. // С делителем 1:2 видим 0-3.1V на ADC что соответствует 0-6.2V на батарее. // Для AA × 2 (3V max): процент = (V/2 - 0.9) / (1.5 - 0.9) * 100 в нашем диапазоне. // Упрощённая формула под dev-сборку: if (avg < 1500) return 0; if (avg > 3000) return 100; return (uint8_t)((avg - 1500) * 100 / 1500); #else return 100; // USB-питание — батарея «полная» всегда #endif } static uint8_t computeStatus() { uint8_t s = 0; if (g_pulseActive) s |= STATUS_BIT_RELAY_ACTIVE; if (readBatteryPercent() < 20) s |= STATUS_BIT_LOW_BATTERY; if (g_rtcValid) s |= STATUS_BIT_TIME_SYNCED; #if NTP_ENABLED if (g_lastNtpSyncMs > 0) s |= STATUS_BIT_NTP_OK; #endif return s; } static void buildAdvertise(NimBLEAdvertising* adv) { cfg.counter++; // Сохраняем в RTC slow memory — переживёт следующий deep_sleep, counter // продолжит расти монотонно вместо сброса в NVS-значение. g_persistedCounter = cfg.counter; g_persistedCounterValid = true; // Layout (23 байта mfg-data после COMPANY_ID = 25 байт всего): // [0..1] COMPANY_ID // [2..5] did (ownerDeviceId) // [6..9] counter // [10..17] HMAC(ownerSecret, did||counter||sleep||opts_cipher)[0..7] // — подпись + nonce для fire (любая модификация opts ломает её) // [18] sleep_interval_s — plaintext (нужно гостю для UI-окна) // [19] battery% ^ keystream[0] зашифровано // [20] status_flags ^ keystream[1] (relay_active, low_bat, time_synced, tamper, ntp_ok) // [21] fw_version ^ keystream[2] (major<<4 | minor) // [22] hw_model ^ keystream[3] (0=unknown, 1=C3, 2=S3, ...) // // keystream = HMAC(ownerSecret, "ks" || did || counter)[0..3] // — отдельный HMAC для XOR-keystream. Outsider не знает ownerSecret, // не может расшифровать opts (нерасшифрованный шум). // — Owner и гость с derived guestKey: только owner может расшифровать // (у гостя нет ownerSecret). Это by design — статус видит хозяин. uint8_t buf[26]; // 23 база + опционально 3 байта state (enc 1 + mac 2) для BISTABLE buf[0] = COMPANY_ID & 0xff; buf[1] = (COMPANY_ID >> 8) & 0xff; buf[2] = (cfg.ownerDeviceId) & 0xff; buf[3] = (cfg.ownerDeviceId >> 8) & 0xff; buf[4] = (cfg.ownerDeviceId >> 16) & 0xff; buf[5] = (cfg.ownerDeviceId >> 24) & 0xff; buf[6] = (cfg.counter) & 0xff; buf[7] = (cfg.counter >> 8) & 0xff; buf[8] = (cfg.counter >> 16) & 0xff; buf[9] = (cfg.counter >> 24) & 0xff; #if DEEP_SLEEP_ENABLED buf[18] = (uint8_t)(DEEP_SLEEP_WAKE_INTERVAL_S & 0xff); #else buf[18] = 0; #endif // Plaintext значения opts uint8_t opts_plain[4]; opts_plain[0] = readBatteryPercent(); opts_plain[1] = computeStatus(); opts_plain[2] = (uint8_t)(((FW_VERSION_MAJOR & 0x0F) << 4) | (FW_VERSION_MINOR & 0x0F)); opts_plain[3] = (uint8_t)(HW_MODEL & 0xff); // Keystream: HMAC(secret, "ks" || did || counter)[0..3] uint8_t ks_in[2 + 8]; ks_in[0] = 'k'; ks_in[1] = 's'; memcpy(ks_in + 2, buf + 2, 8); // did + counter uint8_t ks_mac[32]; hmacSha256(cfg.ownerSecret, 32, ks_in, sizeof(ks_in), ks_mac); // Шифрование XOR-ом и запись в buf[19..22] for (int i = 0; i < 4; i++) buf[19 + i] = opts_plain[i] ^ ks_mac[i]; // Auth HMAC: покрывает did+counter+sleep+opts_cipher. // mac_input = buf[2..9] || buf[18] || buf[19..22] = 13 байт uint8_t mac_in[13]; memcpy(mac_in, buf + 2, 8); // did + counter mac_in[8] = buf[18]; // sleep memcpy(mac_in + 9, buf + 19, 4); // opts_cipher uint8_t mac[32]; hmacSha256(cfg.ownerSecret, 32, mac_in, sizeof(mac_in), mac); memcpy(buf + 10, mac, 8); // Эти 8 байт HMAC одновременно служат nonce'ом для следующего fire. // Клиент с owner_secret видит их в эфире, вычисляет HMAC(secret, nonce) // и шлёт fire без round-trip'а на CHAR_NONCE read. Cryptographic-rand // от точки зрения наблюдателя без секрета. Поскольку HMAC теперь покрывает // и opts_cipher, любая мутация opts в эфире → разный nonce → клиент // получит nonce которого нет в ring и fire отвергнется (защита от // bit-flip атак). pushNonceToRing(buf + 10); // ── State-байт (open/closed) для BISTABLE — под K_state, читается и гостем ── // Только в bistable: момент-режим состояния не имеет (см. DESIGN_OBJECT_STATE). // buf[23] = state_enc = state_byte XOR K_state[counter & 15] // buf[24..25] = state_mac = HMAC(K_state, counter[4 LE] || state_enc)[0..1] // state_byte: бит0 = lock (1=open, 0=closed), биты 1-7 резерв. // MAC 2 байта (не 4 как изначально в доке) — иначе не влезаем в 31-байтный // лимит advertise. Для 1-битного состояния + монотонный counter (anti-replay) // 16 бит достаточно. Гость без K_state state не подделает. size_t mfgLen = 23; if (BISTABLE) { uint8_t state_byte = g_lockOpen ? 0x01 : 0x00; buf[23] = state_byte ^ g_kState[cfg.counter & 15]; uint8_t sin[5]; memcpy(sin, buf + 6, 4); // counter (LE) sin[4] = buf[23]; // state_enc uint8_t smac[32]; hmacSha256(g_kState, 16, sin, sizeof(sin), smac); buf[24] = smac[0]; buf[25] = smac[1]; mfgLen = 26; } // Legacy BLE adv = 31 байт. flags(3) + name(9) + svc128(18) + mfg(20) = 50 — // не влезает, стек ESP молча кидает половину в scan-response. Android // passive-scan через PendingIntent качает только primary, scan-response // не достаёт → mfg-data до приёмника не доходит. // Решение: в primary только mfg-data (3+20=23B), имя и UUID в scan-response. // Android-фильтр на стороне приложения — по ManufacturerData(0x00E0). NimBLEAdvertisementData primary; // NimBLE-Arduino принимает std::string. Конструктор с явной длиной // сохраняет байты как есть, включая 0x00 внутри. std::string md((const char*)buf, mfgLen); primary.setManufacturerData(md); adv->setAdvertisementData(primary); NimBLEAdvertisementData scanResp; scanResp.setName("entrixy"); scanResp.setCompleteServices(NimBLEUUID(SVC_UUID)); adv->setScanResponseData(scanResp); Serial.printf("[adv] name=entrixy did=0x%08x ctr=%u sleep=%u batt=%u status=0x%02x fw=v%u.%u hw=%u\n", cfg.ownerDeviceId, cfg.counter, (unsigned)buf[18], (unsigned)opts_plain[0], (unsigned)opts_plain[1], (unsigned)((opts_plain[2] >> 4) & 0x0F), (unsigned)(opts_plain[2] & 0x0F), (unsigned)opts_plain[3]); if (BISTABLE) Serial.printf("[adv] state=%s (enc=%02x mac=%02x%02x)\n", g_lockOpen ? "OPEN" : "CLOSED", buf[23], buf[24], buf[25]); logHex("adv:mfg", buf, mfgLen); } // ============================================================================= // ECDH X25519 pair-flow // ============================================================================= static mbedtls_ecp_group g_grp; static mbedtls_mpi g_priv; static mbedtls_ecp_point g_pub; static mbedtls_ctr_drbg_context g_drbg; static mbedtls_entropy_context g_entropy; static volatile bool g_pairGotIt = false; // Активен только пока крутится pair-loop. После выхода (по таймауту или успеху) // ставим false — нужно чтобы запоздавший write на CHAR_PAIR_DONE не пытался // считать ECDH с уже освобождённым контекстом (cfg unchanged, но логи путают). static volatile bool g_pairActive = false; static int ecdhInit() { mbedtls_ecp_group_init(&g_grp); mbedtls_mpi_init(&g_priv); mbedtls_ecp_point_init(&g_pub); mbedtls_ctr_drbg_init(&g_drbg); mbedtls_entropy_init(&g_entropy); const char* pers = "entrixy-pair"; int rc = mbedtls_ctr_drbg_seed(&g_drbg, mbedtls_entropy_func, &g_entropy, (const unsigned char*)pers, strlen(pers)); Serial.printf("[ecdh] ctr_drbg_seed rc=%d\n", rc); if (rc) return rc; rc = mbedtls_ecp_group_load(&g_grp, MBEDTLS_ECP_DP_CURVE25519); Serial.printf("[ecdh] group_load(Curve25519) rc=%d\n", rc); if (rc) return rc; rc = mbedtls_ecp_gen_keypair(&g_grp, &g_priv, &g_pub, mbedtls_ctr_drbg_random, &g_drbg); Serial.printf("[ecdh] gen_keypair rc=%d\n", rc); return rc; } static int ecdhExportEspPub(uint8_t* out32) { int rc = mbedtls_mpi_write_binary_le(&g_pub.MBEDTLS_PRIVATE(X), out32, 32); Serial.printf("[ecdh] export pub rc=%d\n", rc); if (rc == 0) logHex("esp_pub", out32, 32); return rc; } static int ecdhDeriveSecret(const uint8_t* peerPub32, uint8_t* outSecret32) { logHex("peer_pub", peerPub32, 32); mbedtls_ecp_point peer; mbedtls_ecp_point_init(&peer); int rc = mbedtls_mpi_read_binary_le(&peer.MBEDTLS_PRIVATE(X), peerPub32, 32); Serial.printf("[ecdh] read peer.X rc=%d\n", rc); if (rc == 0) rc = mbedtls_mpi_lset(&peer.MBEDTLS_PRIVATE(Z), 1); mbedtls_mpi z; mbedtls_mpi_init(&z); if (rc == 0) { rc = mbedtls_ecdh_compute_shared(&g_grp, &z, &peer, &g_priv, mbedtls_ctr_drbg_random, &g_drbg); Serial.printf("[ecdh] compute_shared rc=%d\n", rc); } uint8_t shared[32]; if (rc == 0) rc = mbedtls_mpi_write_binary_le(&z, shared, 32); if (rc == 0) logHex("shared_z", shared, 32); if (rc == 0) { const uint8_t salt[] = "entrixy-pair-v1"; const uint8_t info[] = "owner-secret"; rc = hkdfSha256(salt, sizeof(salt) - 1, shared, 32, info, sizeof(info) - 1, outSecret32, 32); Serial.printf("[ecdh] HKDF rc=%d\n", rc); if (rc == 0) logHex("owner_secret", outSecret32, 32); } mbedtls_mpi_free(&z); mbedtls_ecp_point_free(&peer); return rc; } static void ecdhFree() { mbedtls_ecp_point_free(&g_pub); mbedtls_mpi_free(&g_priv); mbedtls_ecp_group_free(&g_grp); mbedtls_ctr_drbg_free(&g_drbg); mbedtls_entropy_free(&g_entropy); } class PairDoneCallback : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override { std::string val = c->getValue(); Serial.printf("[pair] PAIR_DONE write: len=%u\n", val.length()); if (!g_pairActive) { Serial.println("[pair] write after pair-window closed — ignoring"); return; } if (val.length() < 36) { Serial.println("[pair] payload too short (<36) — ignoring"); return; } const uint8_t* p = (const uint8_t*)val.c_str(); logHex("pair_done_payload[0..35]", p, 36); uint8_t secret[32]; int rc = ecdhDeriveSecret(p, secret); if (rc != 0) { Serial.printf("[pair] ECDH FAILED rc=%d — cfg unchanged\n", rc); return; } memcpy(cfg.ownerSecret, secret, 32); cfg.ownerDeviceId = 0; for (int i = 0; i < 4; i++) cfg.ownerDeviceId |= (uint32_t)p[32+i] << (8*i); cfg.counter = 0; cfg.paired = true; Serial.printf("[pair] did_from_payload = 0x%08x\n", cfg.ownerDeviceId); saveConfig(); g_pairGotIt = true; Serial.println("[pair] *** PAIRED OK *** exiting pair-mode loop"); } }; // ============================================================================= // Pair-режим // ============================================================================= static void enterPairMode() { Serial.println("[pair] entering pair-mode"); digitalWrite(PIN_LED, LED_LEVEL_ON); uint32_t until = millis() + PAIR_WINDOW_MS; if (ecdhInit() != 0) { ecdhFree(); Serial.println("[pair] ecdhInit failed"); return; } uint8_t espPub[32] = {0}; if (ecdhExportEspPub(espPub) != 0) { ecdhFree(); Serial.println("[pair] export pub failed"); return; } NimBLEDevice::init("entrixy-pair"); NimBLEServer* svr = NimBLEDevice::createServer(); NimBLEService* svc = svr->createService(SVC_UUID); NimBLECharacteristic* pubCh = svc->createCharacteristic( CHAR_PAIR_PUB_UUID, NIMBLE_PROPERTY::READ); pubCh->setValue(espPub, 32); NimBLECharacteristic* doneCh = svc->createCharacteristic( CHAR_PAIR_DONE_UUID, NIMBLE_PROPERTY::WRITE); doneCh->setCallbacks(new PairDoneCallback()); svc->start(); // 128-bit SVC_UUID(18B) + name(14B) + flags(3B) = 35 > 31-байтный legacy ADV. // NimBLE по умолчанию молча кидает половину в scan-response, и Android-фильтр // на serviceUUID в primary не срабатывает. Делим явно: UUID в primary, имя в SR. NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); NimBLEAdvertisementData primary; primary.setCompleteServices(NimBLEUUID(SVC_UUID)); adv->setAdvertisementData(primary); NimBLEAdvertisementData scanResp; scanResp.setName("entrixy-pair"); adv->setScanResponseData(scanResp); adv->start(); Serial.printf("[pair] advertising as entrixy-pair, waiting up to %u ms\n", PAIR_WINDOW_MS); g_pairGotIt = false; g_pairActive = true; while (millis() < until && !g_pairGotIt) { delay(50); // Мигаем с периодом 200мс — заодно учитываем полярность LED. bool blink = ((millis() / 200) % 2) == 1; digitalWrite(PIN_LED, blink ? LED_LEVEL_ON : LED_LEVEL_OFF); } g_pairActive = false; adv->stop(); // NimBLEDevice::deinit() в NimBLE 2.x при повторном init или после adv->stop() // приводит к use-after-free и Guru Meditation. Не вызываем — стек всё равно // сбросится через deep_sleep. ecdhFree(); if (g_pairGotIt) { Serial.println("[pair] success, transitioning to work-mode"); } else { Serial.println("[pair] timeout, no client paired"); } digitalWrite(PIN_LED, g_pairGotIt ? LED_LEVEL_ON : LED_LEVEL_OFF); delay(500); digitalWrite(PIN_LED, LED_LEVEL_OFF); } // ============================================================================= // Pair button: short = pair-mode, long = factory reset // ============================================================================= static void checkPairButton() { if (digitalRead(PIN_PAIR_BTN) != LOW) return; Serial.println("[btn] pressed, measuring hold time..."); uint32_t held = 0; while (digitalRead(PIN_PAIR_BTN) == LOW && held < PAIR_LONG_PRESS_MS + 500) { delay(50); held += 50; } Serial.printf("[btn] held %u ms\n", held); if (held >= PAIR_LONG_PRESS_MS) { Serial.println("[btn] LONG → factory reset"); // Индикация сброса pairing: оба LED горят 2 секунды. digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_ON); digitalWrite(PIN_LED, LED_LEVEL_ON); delay(2000); digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_OFF); digitalWrite(PIN_LED, LED_LEVEL_OFF); wipeConfig(); g_rtcValid = false; g_rtcEpochMs = 0; enterPairMode(); } else if (!cfg.paired) { Serial.println("[btn] SHORT (unpaired) → pair-mode"); enterPairMode(); } else { Serial.println("[btn] SHORT (paired) → no-op"); } } // ============================================================================= // setup / loop // ============================================================================= void setup() { // SERIAL_DEBUG=false → НЕ инициализируем UART0. Это (а) убирает отладку в // рабочем режиме, (б) освобождает пины UART0 (на ESP32-S3 это GPIO43/44), // которые можно повесить на открыть/закрыть. Без Serial.begin все вызовы // Serial.print() — безопасные no-op'ы (HardwareSerial: _uart==null → return), // пины не трогаются. if (SERIAL_DEBUG) { Serial.begin(115200); delay(300); Serial.println(); Serial.println("=== Entrixy BLE relay ==="); Serial.printf("Reset reason: %d\n", (int)esp_reset_reason()); } pinMode(PIN_PAIR_BTN, INPUT_PULLUP); pinMode(PIN_LED, OUTPUT); pinMode(PIN_OPEN_LED, OUTPUT); digitalWrite(PIN_LED, LED_LEVEL_OFF); digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_OFF); // «Открыть» = PIN_RELAY в обоих режимах. pinMode(PIN_RELAY, OUTPUT); digitalWrite(PIN_RELAY, RELAY_LEVEL_IDLE); // Bistable: дополнительно пин «закрыть» в покое. if (BISTABLE) { pinMode(PIN_CLOSE, OUTPUT); digitalWrite(PIN_CLOSE, CLOSE_OFF_LEVEL); } // ESP32 при wakeup из deep_sleep сохраняет системное время через RTC slow // clock — settimeofday() с сохранённым ratchet ТОЛЬКО ломает это (затирает // правильно увеличенное время своим старым значением). Делаем restore // только на холодном старте; на wake-from-sleep оставляем как есть. esp_sleep_wakeup_cause_t wakeCause = esp_sleep_get_wakeup_cause(); bool fromDeepSleep = (wakeCause == ESP_SLEEP_WAKEUP_TIMER) || (wakeCause == ESP_SLEEP_WAKEUP_EXT0) || (wakeCause == ESP_SLEEP_WAKEUP_EXT1); if (!fromDeepSleep) { rtcRestore(); } loadConfig(fromDeepSleep); // Bistable: реле НЕ трогаем на старте — защёлка физически держит своё положение // сама, а логическое g_lockOpen уже загружено из NVS (для индикатора и // направления следующего переключения). Импульс делается только по fire. #if NTP_ENABLED loadWifiCreds(); #endif // Внешний RTC — приоритетный источник времени если есть. Запускаем после // loadConfig (чтобы g_timeRatchetMs из NVS был известен — для rollback- // защиты). Если чип отвечает и говорит время >= ratchet — берём его как // авторитет. Если меньше ratchet — рассматриваем как rollback (севшая // батарейка чипа?), игнорируем и переписываем чип нашим ratchet'ом. #if EXT_RTC_TYPE != EXT_RTC_NONE extRtcInit(); { uint64_t extMs = extRtcReadMs(); if (extMs > 0) { if (extMs >= g_timeRatchetMs) { Serial.printf("[rtc] using EXT_RTC epoch_ms=%llu (over NVS ratchet=%llu)\n", (unsigned long long)extMs, (unsigned long long)g_timeRatchetMs); g_baseEpochMs = extMs; g_timeRatchetMs = extMs; struct timeval tv; tv.tv_sec = extMs / 1000; tv.tv_usec = (extMs % 1000) * 1000; settimeofday(&tv, nullptr); g_rtcValid = true; g_rtcEpochMs = extMs; saveRatchet(); } else { Serial.printf("[rtc-ext] rollback detected (chip=%llu < ratchet=%llu), " "overwriting chip with our ratchet\n", (unsigned long long)extMs, (unsigned long long)g_timeRatchetMs); extRtcWriteMs(g_timeRatchetMs); } } else if (g_timeRatchetMs > 0) { // Чип с заводским 2000 годом — bootstrap'им его нашим ratchet. Serial.printf("[rtc-ext] chip blank, bootstrapping with ratchet=%llu\n", (unsigned long long)g_timeRatchetMs); extRtcWriteMs(g_timeRatchetMs); } } #endif #if DEEP_SLEEP_ENABLED g_wakeCount++; g_isNtpWake = (DEEP_SLEEP_NTP_EVERY_N_WAKES > 0) && (g_wakeCount % DEEP_SLEEP_NTP_EVERY_N_WAKES == 0); Serial.printf("[sleep] wake #%u isNtpWake=%d fromDeepSleep=%d\n", (unsigned)g_wakeCount, (int)g_isNtpWake, (int)fromDeepSleep); #endif // 1. Если не привязан — pair-mode сразу. if (!cfg.paired) { Serial.println("[setup] not paired → enterPairMode"); enterPairMode(); if (!cfg.paired) { Serial.println("[setup] still not paired → sleeping until button"); enableWakeupOnButton(); esp_deep_sleep_start(); } // После УСПЕШНОГО pair'а делаем чистый ESP.restart() вместо // продолжения setup(). NimBLE 2.x не разрешает deinit+init в одном // процессе без use-after-free; в результате pair-сервис остаётся // зарегистрированным параллельно с work-сервисом (оба с одним // SVC_UUID), и Android getService(SVC_UUID) возвращает иногда // pair-вариант без CHAR_FIRE. Чистый рестарт гарантирует один // сервис в эфире. Serial.println("[setup] pair OK → restarting for clean work-mode init"); delay(200); ESP.restart(); } checkPairButton(); // 2. Work-mode цикл Serial.println("[setup] entering work-mode advertise"); NimBLEDevice::init("entrixy"); NimBLEServer* svr = NimBLEDevice::createServer(); svr->setCallbacks(new WorkConnCallback()); NimBLEService* svc = svr->createService(SVC_UUID); // NimBLE-Arduino: PROPERTY автоматически генерирует permissions, // setAccessPermissions вызывать не нужно. NimBLECharacteristic* nonceCh = svc->createCharacteristic( CHAR_NONCE_UUID, NIMBLE_PROPERTY::READ); nonceCh->setCallbacks(new NonceCallback()); NimBLECharacteristic* fireCh = svc->createCharacteristic( CHAR_FIRE_UUID, NIMBLE_PROPERTY::WRITE); fireCh->setCallbacks(new FireCallback()); NimBLECharacteristic* timeCh = svc->createCharacteristic( CHAR_TIME_UUID, NIMBLE_PROPERTY::WRITE); timeCh->setCallbacks(new TimeCallback()); #if NTP_ENABLED NimBLECharacteristic* wifiCh = svc->createCharacteristic( CHAR_WIFI_UUID, NIMBLE_PROPERTY::WRITE); wifiCh->setCallbacks(new WifiCallback()); #endif // RESULT notify — после каждого FIRE/TIME/WIFI write ESP отправляет // 1-байтный код результата. Клиент подписывается перед write и ждёт // notify до 2-3 сек. Позволяет показывать конкретные причины отказа // вместо общего "не удалось". g_resultCh = svc->createCharacteristic( CHAR_RESULT_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); uint8_t initRes = 0xff; g_resultCh->setValue(&initRes, 1); svc->start(); NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); buildAdvertise(adv); adv->start(); g_workConnected = 0; g_workConnectedAt = 0; g_workLastActivity = millis(); } void loop() { static uint32_t lastAdvRefresh = 0; static uint32_t lastBeat = 0; static uint32_t loopStartedAt = 0; uint32_t now = millis(); if (loopStartedAt == 0) loopStartedAt = now; // Неблокирующий relay-pulse: callback FIRE write только взвёл флаг, // здесь дёргаем GPIO и через 500мс отпускаем. Не блокируем NimBLE. servicePulse(); #if NTP_ENABLED // На обычных wake'ах при включённом sleep — WiFi не поднимаем (батарея). // Только на NTP-wake'ах. Если sleep выключен — wifiNtpService работает // непрерывно как раньше. #if DEEP_SLEEP_ENABLED if (g_isNtpWake) wifiNtpService(); #else wifiNtpService(); #endif #endif // Hard-timeout залипшего GATT: если клиент висит подключённым, но >60с // ни одного read/write/notify — форсим disconnect всех. Иначе одна "мёртвая" // сессия (телефон ушёл из зоны, не отправив disconnect) держит ESP в эфире // и съедает батарею до bluetoothd supervision-timeout (обычно 5–20 сек, но // бывает гораздо дольше при крашах стека). if (g_workConnected > 0 && (now - g_workLastActivity) > STUCK_CONN_TIMEOUT_MS) { Serial.printf("[ble] STUCK conn timeout (count=%d, idle=%lums) — force disconnect all\n", (int)g_workConnected, (unsigned long)(now - g_workLastActivity)); NimBLEServer* srv = NimBLEDevice::getServer(); if (srv) { auto peers = srv->getPeerDevices(); for (auto h : peers) srv->disconnect(h); } g_workConnected = 0; g_workLastActivity = now; } #if DEEP_SLEEP_ENABLED // Если awake-окно вышло и нет активного клиента — в сон. uint32_t awakeMs = g_isNtpWake ? DEEP_SLEEP_AWAKE_MS_NTP : DEEP_SLEEP_AWAKE_MS; if (g_workConnected == 0 && !g_pulseActive && (now - loopStartedAt) >= awakeMs) { Serial.printf("[sleep] awake_window done, sleeping for %u s\n", DEEP_SLEEP_WAKE_INTERVAL_S); rtcSave(); esp_sleep_enable_timer_wakeup((uint64_t)DEEP_SLEEP_WAKE_INTERVAL_S * 1000000ULL); enableWakeupOnButton(); esp_deep_sleep_start(); } #endif // Раз в секунду обновляем advertise (новый counter + HMAC). if (now - lastAdvRefresh > 1000) { lastAdvRefresh = now; NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); adv->stop(); buildAdvertise(adv); adv->start(); } // Heartbeat в Serial — видно, подключился ли клиент и шли ли GATT-операции. if (now - lastBeat > 1000) { lastBeat = now; Serial.printf("[loop] alive ms=%lu connected=%d reads=%u writes=%u lastWriteLen=%u\n", (unsigned long)now, g_workConnected, (unsigned)g_readCount, (unsigned)g_writeCount, (unsigned)g_lastWriteLen); } // Long-press кнопки = factory reset. if (digitalRead(PIN_PAIR_BTN) == LOW) { delay(50); if (digitalRead(PIN_PAIR_BTN) == LOW) { uint32_t held = 0; while (digitalRead(PIN_PAIR_BTN) == LOW && held < PAIR_LONG_PRESS_MS + 500) { delay(50); held += 50; } if (held >= PAIR_LONG_PRESS_MS) { Serial.println("[btn] LONG → factory reset"); // Индикация: оба LED горят 2с перед перезагрузкой. digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_ON); digitalWrite(PIN_LED, LED_LEVEL_ON); delay(2000); digitalWrite(PIN_OPEN_LED, OPEN_LED_LEVEL_OFF); digitalWrite(PIN_LED, LED_LEVEL_OFF); wipeConfig(); delay(300); ESP.restart(); } } } delay(20); }