Ограничить блокировку loop() при отправке webhook бюджетом 3 с
BearSSL делает TLS-хендшейк синхронно, поэтому вместо асинхронности блокировка ограничивается по времени. Главный источник залипания — не таймауты HTTPClient, а DNS: WiFiClientSecureCtx::connect(name, port) резолвит имя через WiFi.hostByName() без таймаута и подвешивает loop() на ~10 с. Теперь имя разрешается заранее с лимитом 800 мс, результат попадает в кэш lwIP, и внутренний резолв возвращается мгновенно. Соединение по-прежнему идёт по имени, поэтому SNI и заголовок Host сохраняются. Остаток бюджета уходит в http.setTimeout(): он ограничивает и connect+TLS (через _client->setTimeout() до connect), и чтение ответа (собственный цикл HTTPClient по _tcpTimeout). Худший случай: ~3.5 с вместо прежних 10-15 с. Выходы аварии не затронуты — digitalWrite() и раньше выполнялся до отправки. Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра 3.1.2 — в скетче замечаний нет; разбор URL прогнан на 15 граничных случаях. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -152,6 +152,11 @@ Wi-Fi. Максимальная длина — 220 символов.
|
|||||||
Если в момент события связи не было — уведомление **не будет** отправлено
|
Если в момент события связи не было — уведомление **не будет** отправлено
|
||||||
позже, оно теряется.
|
позже, оно теряется.
|
||||||
|
|
||||||
|
На отправку отводится не больше **3 секунд**. Если сервер недоступен или
|
||||||
|
отвечает медленно, прибор прекращает попытку и продолжает работу: экран и
|
||||||
|
кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не
|
||||||
|
дожидаясь сервера.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Сброс настроек Wi-Fi
|
## 7. Сброс настроек Wi-Fi
|
||||||
@@ -193,7 +198,8 @@ Webhook URL при сбросе Wi-Fi **не стирается**.
|
|||||||
| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя |
|
| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя |
|
||||||
| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) |
|
| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) |
|
||||||
| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора |
|
| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора |
|
||||||
| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод), строка `[HTTP] Код:` |
|
| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя |
|
||||||
|
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Выходы аварии при этом срабатывают сразу, без ожидания сервера |
|
||||||
| Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой |
|
| Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой |
|
||||||
|
|
||||||
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
||||||
|
|||||||
@@ -143,9 +143,44 @@ TLS-соединение поднимается через `WiFiClientSecure` с
|
|||||||
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
||||||
локальную/корпоративную инфраструктуру, но не защищает от MITM.
|
локальную/корпоративную инфраструктуру, но не защищает от 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 без проверки сертификата.
|
* `setInsecure()` — TLS без проверки сертификата.
|
||||||
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
|
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
|
||||||
* Webhook отправляется синхронно и блокирует цикл; при недоступном сервере
|
* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета
|
||||||
реакция контроллера замедляется на время таймаута.
|
(см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется
|
||||||
* События, произошедшие без Wi-Fi, не буферизуются — webhook просто теряется
|
**до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только
|
||||||
(в лог пишется `[HTTP] Нет Wi-Fi`).
|
обновление OLED, опрос кнопки и веб-панель.
|
||||||
|
* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются
|
||||||
|
и не повторяются — webhook теряется, флаги `mainAlarmSent` /
|
||||||
|
`emergencyActiveSent` выставляются независимо от результата POST.
|
||||||
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
|
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
|
||||||
не считается и никак не сигнализируется.
|
не считается и никак не сигнализируется.
|
||||||
* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем,
|
* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем,
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0};
|
|||||||
const unsigned long debounceDelay = 3000;
|
const unsigned long debounceDelay = 3000;
|
||||||
const unsigned long alarmDelay = 5000;
|
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 mainAlarmSent = false;
|
||||||
bool emergencyActiveSent = false;
|
bool emergencyActiveSent = false;
|
||||||
bool systemReady = false;
|
bool systemReady = false;
|
||||||
@@ -265,28 +275,104 @@ void updateDisplay() {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// HTTPS POST
|
// 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) {
|
void sendPostWebhook(String eventName, String status) {
|
||||||
if (webhook_url[0] == '\0') {
|
if (webhook_url[0] == '\0') {
|
||||||
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
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;
|
WiFiClientSecure client;
|
||||||
client.setInsecure();
|
client.setInsecure();
|
||||||
|
client.setTimeout(phaseBudget);
|
||||||
|
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
Serial.print("[HTTP] POST: ");
|
http.setTimeout(phaseBudget); // ограничивает и connect+TLS, и чтение ответа
|
||||||
|
http.setReuse(false); // не держать TLS-сокет после отправки
|
||||||
|
|
||||||
|
Serial.print(F("[HTTP] POST: "));
|
||||||
Serial.println(webhook_url);
|
Serial.println(webhook_url);
|
||||||
if (http.begin(client, webhook_url)) {
|
|
||||||
|
if (!http.begin(client, webhook_url)) {
|
||||||
|
Serial.println(F("[HTTP] begin() не удался"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
http.addHeader("Content-Type", "application/json");
|
http.addHeader("Content-Type", "application/json");
|
||||||
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}";
|
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}";
|
||||||
int code = http.POST(body);
|
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());
|
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();
|
http.end();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Serial.println("[HTTP] Нет Wi-Fi");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user