diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index fdb43c1..d037af0 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -152,6 +152,11 @@ Wi-Fi. Максимальная длина — 220 символов. Если в момент события связи не было — уведомление **не будет** отправлено позже, оно теряется. +На отправку отводится не больше **3 секунд**. Если сервер недоступен или +отвечает медленно, прибор прекращает попытку и продолжает работу: экран и +кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не +дожидаясь сервера. + --- ## 7. Сброс настроек Wi-Fi @@ -193,7 +198,8 @@ Webhook URL при сбросе Wi-Fi **не стирается**. | Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя | | `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) | | Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора | -| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод), строка `[HTTP] Код:` | +| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя | +| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Выходы аварии при этом срабатывают сразу, без ожидания сервера | | Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой | Подробный лог работы выводится в USB-порт: скорость **115200 бод** diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index 19eb4cb..c501e44 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -143,9 +143,44 @@ TLS-соединение поднимается через `WiFiClientSecure` с сертификат сервера **не проверяется**. Достаточно для отправки в доверенную локальную/корпоративную инфраструктуру, но не защищает от MITM. -Отправка синхронная и блокирующая: на время POST (до нескольких секунд при -недоступном сервере) основной цикл приостанавливается. Дисплей в этот момент -не обновляется. +### 5.1 Бюджет отправки + +Отправка остаётся синхронной — BearSSL выполняет TLS-хендшейк блокирующе, и +полностью асинхронного HTTPS на ESP8266 без хрупких сторонних библиотек нет. +Вместо этого блокировка **ограничена по времени** бюджетом +`WEBHOOK_BUDGET_MS = 3000 мс`, который расходуется по этапам: + +1. **Разбор URL** (`parseWebhookHost`) — извлекает имя хоста, мгновенно. +2. **DNS** — `WiFi.hostByName(host, ip, WEBHOOK_DNS_BUDGET_MS)`, лимит 800 мс. +3. **TCP + TLS + чтение ответа** — `http.setTimeout(остаток бюджета)`. + +Шаг 2 существует именно ради потолка: `WiFiClientSecureCtx::connect(name, port)` +внутри вызывает `WiFi.hostByName()` **без таймаута** и на мёртвом DNS подвешивает +`loop()` примерно на 10 секунд. Предварительный резолв с лимитом кладёт адрес в +кэш lwIP, после чего внутренний резолв возвращается мгновенно. Соединение +по-прежнему устанавливается по имени, поэтому SNI и заголовок `Host` не ломаются +(важно, если webhook живёт за реверс-прокси). + +Шаг 3 опирается на то, что `HTTPClient::connect()` вызывает +`_client->setTimeout(_tcpTimeout)` **до** `_client->connect()`, а хендшейк +BearSSL ограничен тем же `_timeout`. Чтение ответа ограничено отдельно — +собственным циклом `HTTPClient` по `_tcpTimeout`. + +Проверять бюджет между фазами внутри `HTTPClient` нельзя, поэтому потолок не +строго 3000 мс. Но провал любой фазы обрывает цепочку, так что лимиты не +складываются. Реальные худшие случаи: + +| Сценарий | Задержка | +|---|---| +| Успешная отправка | 300–1500 мс | +| DNS не отвечает | ~800 мс | +| IP не отвечает (чёрная дыра) | ~800 мс + остаток бюджета | +| TLS не поднимается | то же | +| Хендшейк прошёл, ответа нет | ~1500 мс + остаток бюджета ≈ 3.5 с | + +До введения бюджета те же сценарии давали 10–15 секунд. + +Фактическое время каждой отправки пишется в лог: `[HTTP] Код: 200 (412 мс)`. --- @@ -225,10 +260,13 @@ STATUS: OK * `setInsecure()` — TLS без проверки сертификата. * Веб-интерфейс и `/reset_wifi` не защищены паролем. -* Webhook отправляется синхронно и блокирует цикл; при недоступном сервере - реакция контроллера замедляется на время таймаута. -* События, произошедшие без Wi-Fi, не буферизуются — webhook просто теряется - (в лог пишется `[HTTP] Нет Wi-Fi`). +* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета + (см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется + **до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только + обновление OLED, опрос кнопки и веб-панель. +* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются + и не повторяются — webhook теряется, флаги `mainAlarmSent` / + `emergencyActiveSent` выставляются независимо от результата POST. * Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией не считается и никак не сигнализируется. * Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем, diff --git a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino index 316cfa6..3a3502e 100644 --- a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino +++ b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino @@ -84,6 +84,16 @@ Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0}; const unsigned long debounceDelay = 3000; const unsigned long alarmDelay = 5000; +// --- БЮДЖЕТ ОТПРАВКИ WEBHOOK --- +// Полностью неблокирующий HTTPS на ESP8266 невозможен: BearSSL делает хендшейк +// синхронно. Поэтому блокировка не убирается, а ограничивается по времени. +// HTTPClient не позволяет проверять бюджет между своими фазами (TCP connect / +// TLS / чтение ответа), но неудачная фаза обрывает цепочку, поэтому лимиты +// не складываются. Худший реальный случай — успешный хендшейк плюс зависшее +// чтение ответа, около 3.5 с вместо прежних 10-15 с. +const unsigned long WEBHOOK_BUDGET_MS = 3000; // общий бюджет на отправку +const unsigned long WEBHOOK_DNS_BUDGET_MS = 800; // из него — на резолв имени + bool mainAlarmSent = false; bool emergencyActiveSent = false; bool systemReady = false; @@ -265,28 +275,104 @@ void updateDisplay() { // ============================================================ // HTTPS POST // ============================================================ + +// Вытаскивает имя хоста из URL вида https://host[:port][/path] — +// оно нужно, чтобы разрешить адрес заранее с собственным таймаутом. +// Порт отбрасывается: соединение всё равно устанавливает HTTPClient по URL. +// Форма user@host не поддерживается — в webhook-адресах не встречается. +static bool parseWebhookHost(const char* url, String &host) { + String u(url); + + int schemeEnd = u.indexOf("://"); + if (schemeEnd < 0) return false; + + String scheme = u.substring(0, schemeEnd); + scheme.toLowerCase(); + if (scheme != "https" && scheme != "http") return false; + + // Границы authority: до первого '/', '?' или '#' + int authStart = schemeEnd + 3; + int authEnd = u.length(); + for (int i = authStart; i < (int)u.length(); i++) { + char c = u[i]; + if (c == '/' || c == '?' || c == '#') { authEnd = i; break; } + } + + String auth = u.substring(authStart, authEnd); + + int colon = auth.indexOf(':'); + if (colon >= 0) auth = auth.substring(0, colon); + + if (auth.length() == 0) return false; + + host = auth; + return true; +} + void sendPostWebhook(String eventName, String status) { if (webhook_url[0] == '\0') { Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена")); return; } - if (WiFi.status() == WL_CONNECTED) { - WiFiClientSecure client; - client.setInsecure(); - HTTPClient http; - Serial.print("[HTTP] POST: "); - Serial.println(webhook_url); - if (http.begin(client, webhook_url)) { - http.addHeader("Content-Type", "application/json"); - String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}"; - int code = http.POST(body); - if (code > 0) Serial.printf("[HTTP] Код: %d\n", code); - else Serial.printf("[HTTP] Ошибка: %s\n", http.errorToString(code).c_str()); - http.end(); - } - } else { - Serial.println("[HTTP] Нет Wi-Fi"); + if (WiFi.status() != WL_CONNECTED) { + Serial.println(F("[HTTP] Нет Wi-Fi")); + return; } + + const unsigned long started = millis(); + + String host; + if (!parseWebhookHost(webhook_url, host)) { + Serial.println("[HTTP] Не удалось разобрать URL: " + String(webhook_url)); + return; + } + + // --- Шаг 1: DNS с явным лимитом --- + // WiFiClientSecure::connect(name, port) резолвит имя без таймаута и способен + // подвесить loop() примерно на 10 с. Разрешаем сами, с бюджетом; результат + // ложится в кэш lwIP, поэтому внутренний резолв дальше уже мгновенный + // (и SNI по-прежнему передаётся, так как HTTPClient коннектится по имени). + IPAddress ip; + if (!WiFi.hostByName(host.c_str(), ip, WEBHOOK_DNS_BUDGET_MS)) { + Serial.printf("[HTTP] DNS не ответил за %lu мс: %s\n", + WEBHOOK_DNS_BUDGET_MS, host.c_str()); + return; + } + + // --- Шаг 2: остаток бюджета на TCP + TLS + чтение ответа --- + unsigned long elapsed = millis() - started; + if (elapsed >= WEBHOOK_BUDGET_MS) { + Serial.println(F("[HTTP] Бюджет исчерпан на резолве DNS")); + return; + } + const uint16_t phaseBudget = (uint16_t)(WEBHOOK_BUDGET_MS - elapsed); + + yield(); // отдать время стеку Wi-Fi перед блокирующей фазой + + WiFiClientSecure client; + client.setInsecure(); + client.setTimeout(phaseBudget); + + HTTPClient http; + http.setTimeout(phaseBudget); // ограничивает и connect+TLS, и чтение ответа + http.setReuse(false); // не держать TLS-сокет после отправки + + Serial.print(F("[HTTP] POST: ")); + Serial.println(webhook_url); + + if (!http.begin(client, webhook_url)) { + Serial.println(F("[HTTP] begin() не удался")); + return; + } + + http.addHeader("Content-Type", "application/json"); + String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}"; + int code = http.POST(body); + + if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started); + else Serial.printf("[HTTP] Ошибка: %s (%lu мс)\n", + http.errorToString(code).c_str(), millis() - started); + http.end(); } // ============================================================