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>
289 lines
14 KiB
Markdown
289 lines
14 KiB
Markdown
# Pump Controller v8.2 (OLED)
|
||
|
||
Контроллер мониторинга насосной станции на **Wemos D1 Mini (ESP8266)**.
|
||
|
||
Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие
|
||
контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в
|
||
локальной сети и отправляет события на HTTPS-webhook (n8n).
|
||
|
||
---
|
||
|
||
## 1. Состав репозитория
|
||
|
||
| Файл | Назначение |
|
||
|---|---|
|
||
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) |
|
||
| `README.md` | Техническая документация (этот файл) |
|
||
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||
|
||
---
|
||
|
||
## 2. Аппаратная часть
|
||
|
||
### 2.1 Плата
|
||
|
||
Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или 3.3 В на пин `3V3`.
|
||
|
||
### 2.2 Распиновка
|
||
|
||
| Пин | Имя в коде | Режим | Логика |
|
||
|---|---|---|---|
|
||
| `D5` | `IN_PUMP_1` | `INPUT_PULLUP` | `LOW` = насос 1 работает |
|
||
| `D6` | `IN_PUMP_2` | `INPUT_PULLUP` | `LOW` = насос 2 работает |
|
||
| `D7` | `IN_PUMP_EMERGENCY` | `INPUT_PULLUP` | `LOW` = резервный насос работает |
|
||
| `D3` | `BTN_WIFI_RESET` | `INPUT_PULLUP` | `LOW` (замыкание на GND) = кнопка нажата |
|
||
| `D8` | `OUT_ALARM_MAIN` | `OUTPUT` | `HIGH` = авария основных насосов |
|
||
| `D0` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `HIGH` = работает резерв |
|
||
| `D1` | I2C SDA | — | OLED |
|
||
| `D2` | I2C SCL | — | OLED |
|
||
|
||
Замечания по особенностям ESP8266:
|
||
|
||
* `D3` (GPIO0) и `D8` (GPIO15) участвуют в выборе режима загрузки. `D8` должен
|
||
быть подтянут к GND при старте — не вешайте на него нагрузку, тянущую пин вверх.
|
||
Кнопку на `D3` при старте держать нажатой нельзя (плата уйдёт в режим прошивки).
|
||
* `D0` (GPIO16) не имеет внутреннего pull-up (только pull-down) и не поддерживает
|
||
прерывания — используется только как выход.
|
||
|
||
### 2.3 Дисплей
|
||
|
||
SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (константа `OLED_ADDR`).
|
||
Если экран не найден — в лог уходит сообщение, прошивка продолжает работать
|
||
без дисплея (`oledOK = false`).
|
||
|
||
### 2.4 Подключение датчиков
|
||
|
||
Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ).
|
||
Замкнут на GND → насос считается работающим. Внешние резисторы не нужны,
|
||
используется внутренняя подтяжка.
|
||
|
||
---
|
||
|
||
## 3. Зависимости
|
||
|
||
Устанавливаются через Arduino Library Manager:
|
||
|
||
* Adafruit SSD1306
|
||
* Adafruit GFX Library
|
||
* WiFiManager (tzapu)
|
||
|
||
Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`,
|
||
`WiFiClientSecure`, `EEPROM`, `Wire`.
|
||
|
||
Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600, Flash size 4MB.
|
||
|
||
---
|
||
|
||
## 4. Логика работы
|
||
|
||
### 4.1 Антидребезг
|
||
|
||
`updatePumpState()` для каждого входа:
|
||
|
||
```
|
||
если чтение изменилось -> сбросить таймер lastDebounceTime
|
||
если чтение стабильно > 3000 мс -> зафиксировать stableState
|
||
```
|
||
|
||
`debounceDelay = 3000 мс` — длинный намеренно: фильтрует пусковые дребезги
|
||
пускателя и кратковременные просадки.
|
||
|
||
### 4.2 Готовность системы
|
||
|
||
`systemReady` становится `true` через `debounceDelay + 500 мс` после старта.
|
||
До этого момента аварии не формируются и webhook не отправляется — иначе
|
||
при включении питания система рапортовала бы ложную аварию.
|
||
|
||
### 4.3 Основная авария
|
||
|
||
Условие: **оба** основных насоса стоят (`!p1 && !p2`).
|
||
|
||
```
|
||
bothStopped -> запуск таймера mainAlarmStartTime
|
||
выдержка alarmDelay = 5000 мс
|
||
-> OUT_ALARM_MAIN = HIGH
|
||
-> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно)
|
||
|
||
любой насос запустился
|
||
-> OUT_ALARM_MAIN = LOW
|
||
-> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно)
|
||
```
|
||
|
||
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг) + `5 с`
|
||
(выдержка) ≈ **8 секунд**.
|
||
|
||
### 4.4 Резервный насос
|
||
|
||
Реакция без дополнительной выдержки, сразу после стабилизации входа:
|
||
|
||
```
|
||
pE = работает -> OUT_ALARM_EMERGENCY = HIGH, webhook EMERGENCY PUMP / STARTED
|
||
pE = стоит -> OUT_ALARM_EMERGENCY = LOW, webhook EMERGENCY PUMP / STOPPED
|
||
```
|
||
|
||
Флаги `mainAlarmSent` / `emergencyActiveSent` гарантируют отправку ровно
|
||
одного webhook на каждый переход состояния.
|
||
|
||
---
|
||
|
||
## 5. Webhook
|
||
|
||
`POST` на `webhook_url`, `Content-Type: application/json`:
|
||
|
||
```json
|
||
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
|
||
```
|
||
|
||
| `event` | `status` |
|
||
|---|---|
|
||
| `MAIN PUMPS` | `ALARM` / `OK` |
|
||
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
|
||
|
||
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` —
|
||
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
||
локальную/корпоративную инфраструктуру, но не защищает от MITM.
|
||
|
||
### 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 мс)`.
|
||
|
||
---
|
||
|
||
## 6. Хранение настроек (EEPROM)
|
||
|
||
Эмулируемая EEPROM, 256 байт.
|
||
|
||
| Адрес | Размер | Содержимое |
|
||
|---|---|---|
|
||
| `0` | 2 | Magic `0xA55A` — признак инициализации |
|
||
| `2` | 220 | Webhook URL, `\0`-терминированный |
|
||
|
||
При первом старте (magic не совпал) записывается значение константы
|
||
`webhook_url_default` — она намеренно пустая, чтобы секретный токен не попадал
|
||
в исходник и в git. Пока URL не задан через `/set_webhook`, `sendPostWebhook()`
|
||
сразу выходит и пишет в лог `[HTTP] Webhook URL не задан`.
|
||
|
||
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM.
|
||
|
||
---
|
||
|
||
## 7. Веб-интерфейс
|
||
|
||
Сервер на порту `80`.
|
||
|
||
| Маршрут | Метод | Описание |
|
||
|---|---|---|
|
||
| `/` | `GET` | HTML-панель мониторинга |
|
||
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0}` |
|
||
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
||
| `/set_webhook` | `POST` | Параметр `url` (≤220 симв.), сохранить в EEPROM |
|
||
|
||
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при
|
||
изменении состояния. Шрифты подгружаются с Google Fonts — при отсутствии
|
||
интернета у клиента интерфейс отрисуется системным моноширинным шрифтом.
|
||
|
||
Аутентификации нет: любой в той же сети может сбросить Wi-Fi и подменить
|
||
webhook. Контроллер рассчитан на изолированный технологический сегмент.
|
||
|
||
---
|
||
|
||
## 8. OLED
|
||
|
||
Обновление раз в секунду (`DISPLAY_INTERVAL`).
|
||
|
||
```
|
||
PUMP CONTROLLER v8
|
||
────────────────────────
|
||
PUMP 1 : RUNNING
|
||
PUMP 2 : STOPPED
|
||
BACKUP : STANDBY
|
||
────────────────────────
|
||
192.168.1.42
|
||
STATUS: OK
|
||
```
|
||
|
||
Строка статуса:
|
||
|
||
| Условие | Текст |
|
||
|---|---|
|
||
| `!systemReady` | `INIT...` |
|
||
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) |
|
||
| работает резерв | `WARN: BACKUP RUNNING` |
|
||
| иначе | `STATUS: OK` |
|
||
|
||
---
|
||
|
||
## 9. Кнопка сброса Wi-Fi
|
||
|
||
Пин `D3`, удержание `BTN_HOLD_MS = 3000 мс`. Во время удержания на дисплее
|
||
рисуется прогресс-бар с обратным отсчётом. По достижении порога —
|
||
`wm.resetSettings()` и `ESP.restart()`. Отпускание до порога отменяет операцию.
|
||
|
||
---
|
||
|
||
## 10. Известные ограничения
|
||
|
||
* `setInsecure()` — TLS без проверки сертификата.
|
||
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
|
||
* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета
|
||
(см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется
|
||
**до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только
|
||
обновление OLED, опрос кнопки и веб-панель.
|
||
* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются
|
||
и не повторяются — webhook теряется, флаги `mainAlarmSent` /
|
||
`emergencyActiveSent` выставляются независимо от результата POST.
|
||
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
|
||
не считается и никак не сигнализируется.
|
||
* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем,
|
||
кто может её открыть. Если URL содержит секретный токен — доступ к панели
|
||
равносилен доступу к токену.
|
||
|
||
---
|
||
|
||
## 11. Сборка
|
||
|
||
```bash
|
||
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||
```
|
||
|
||
```bash
|
||
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||
```
|
||
|
||
Монитор порта: 115200 бод.
|