Files
Arduino/pump_controller_8_2_OLED_DONE/README.md
T
oskarvitaliiandClaude Opus 5 cf61617a46 Настройки через веб, состояние реле в UI, распиновка под реле active-LOW
Распиновка. Пина D0, который использовался под выход «Резерв», в доступном
наборе нет. Кнопка сброса Wi-Fi удалена как избыточная: при пропавшей сети
wm.autoConnect() сам поднимает портал. Освободившийся D3 закрыл дефицит.

Выходы переехали на D3 и D4 и стали инверсными. Причина: для реле active-LOW
безопасное состояние — пин в HIGH, а D3/D4 подтянуты к HIGH внешними
резисторами платы и держат этот уровень всю загрузку. D8, наоборот, подтянут
к LOW и щёлкал бы реле при каждом включении питания. В setup() digitalWrite()
идёт до pinMode(), иначе защёлка выхода даёт короткий LOW.

Инверсия живёт только в setRelay(); логическое состояние дублируется в
relayMainOn/relayEmergOn, чтобы панель показывала смысл, а не уровень пина.

Настройки. device, выдержка аварии и выдержка резерва вынесены в /set_config
и EEPROM. Раскладка EEPROM версионирована: magic 0xA55B, старый 0xA55A
распознаётся и переносится, поэтому прошитые приборы не теряют webhook.
Значения по умолчанию воспроизводят прежнее поведение (5 с и 0 с).

Для резерва добавлена выдержка, которой раньше не было, симметрично основной
аварии. Обе выдержки гасят реле и вебхук одновременно.

device_name фильтруется до [A-Za-z0-9_-] вместо экранирования: такой набор
безопасен и в JSON, и в HTML. Пустое или неверное поле формы означает
«не менять», поэтому частичное заполнение не сбрасывает остальное.

Состояние реле выведено в /status и в панель.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра
3.1.2 — предупреждений в скетче нет (попутно убран неиспользуемый
isEmergency); валидация настроек прогнана на 22 граничных случаях;
раскладка EEPROM проверена на перекрытия. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:02:21 +07:00

423 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | Руководство пользователя / монтажника |
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
---
## 2. Аппаратная часть
### 2.1 Плата
Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или 3.3 В на пин `3V3`.
### 2.2 Распиновка
Выходы рассчитаны на релейные модули **active-LOW** (`LOW` на входе = реле
включено).
| Пин | Имя в коде | Режим | Логика |
|---|---|---|---|
| `D1` | I2C SDA | — | OLED |
| `D2` | I2C SCL | — | OLED |
| `D5` | `IN_PUMP_1` | `INPUT_PULLUP` | `LOW` = насос 1 работает |
| `D6` | `IN_PUMP_2` | `INPUT_PULLUP` | `LOW` = насос 2 работает |
| `D7` | `IN_PUMP_EMERGENCY` | `INPUT_PULLUP` | `LOW` = резервный насос работает |
| `D3` | `OUT_ALARM_MAIN` | `OUTPUT` | `LOW` = реле «Авария» включено |
| `D4` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `LOW` = реле «Резерв» включено |
| `D8`, `D0`, `A0` | — | — | не используются |
### 2.3 Почему выходы именно на D3 и D4
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания.
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|---|---|---|---|
| `D3` | 0 | подтянут `HIGH` (10к) | выключено ✓ |
| `D4` | 2 | подтянут `HIGH` (10к) | выключено ✓ |
| `D8` | 15 | подтянут **`LOW`** (10к) | **включено** ✗ |
| `D1`,`D2`,`D5`,`D6`,`D7` | 5,4,14,12,13 | не определён до `pinMode` | неопределённо |
Для реле active-**HIGH** вывод был бы обратным: `D8` — лучший пин, `D3`/`D4`
худшие. Логика выходов и выбор пинов связаны жёстко; менять одно без другого
нельзя.
В `setup()` порядок операций тоже важен: `digitalWrite(pin, HIGH)` вызывается
**до** `pinMode(pin, OUTPUT)`, иначе защёлка выхода может кратковременно выдать
`LOW`.
Инверсия уровня локализована в одной функции — единственном месте, где она
существует:
```cpp
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
```
### 2.4 Прочие особенности ESP8266
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
«Резерв» получает бесплатную индикацию на плате.
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
Косметика, но пугает.
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
цифровых пинов не входит.
* Схема **не fail-safe**: авария включает реле, поэтому обесточенный контроллер
сигнала не даёт. Если нужна отказобезопасность — снимайте нагрузку с
нормально замкнутого (NC) контакта реле, тогда пропажа питания читается как
авария.
### 2.5 Дисплей
SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (константа `OLED_ADDR`).
Если экран не найден — в лог уходит сообщение, прошивка продолжает работать
без дисплея (`oledOK = false`).
### 2.6 Подключение датчиков
Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ).
Замкнут на 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
выдержка alarmDelaySec (настраивается, по умолчанию 5 с)
-> реле «Авария» включено
-> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно)
любой насос запустился
-> реле «Авария» выключено
-> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно)
```
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг, константа
`debounceDelay`) + `alarmDelaySec`. При значениях по умолчанию ≈ **8 секунд**.
### 4.4 Резервный насос
Логика симметрична основной аварии — та же схема «ожидание, затем реакция»:
```
pE = работает -> запуск таймера emergStartTime
выдержка emergDelaySec (настраивается, по умолчанию 0 с)
-> реле «Резерв» включено
-> webhook EMERGENCY PUMP / STARTED (однократно)
pE = стоит -> реле «Резерв» выключено
-> webhook EMERGENCY PUMP / STOPPED (однократно)
```
При `emergDelaySec = 0` условие `millis() - emergStartTime >= 0` выполняется на
следующей же итерации `loop()`, то есть реакция мгновенная.
### 4.5 Однократность и общая выдержка
Обе выдержки гасят **и реле, и webhook одновременно** — это одна настройка на
канал, а не отдельные таймеры для реле и уведомления.
Флаги `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` |
Поле `device` берётся из настройки `device_name` (задаётся через веб-панель,
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
запроса ему нечем.
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, 512 байт. Занято 258.
| Адрес | Размер | Содержимое | По умолчанию |
|---|---|---|---|
| `0` | 2 | Magic `0xA55B` — версия раскладки 2 | — |
| `2` | 220 | `webhook_url`, `\0`-терминированный | пусто |
| `222` | 31 | `device_name` | `Wemos_D1_Pump` |
| `254` | 2 | `alarm_delay_sec` (`uint16`) | `5` |
| `256` | 2 | `emerg_delay_sec` (`uint16`) | `0` |
Значения по умолчанию воспроизводят поведение прошивки до появления настроек.
### 6.1 Версионирование и миграция
Magic одновременно служит номером версии раскладки:
| Прочитанный magic | Действие |
|---|---|
| `0xA55B` | читаются все поля |
| `0xA55A` | **миграция v1 → v2**: `webhook_url` сохраняется, новые поля получают значения по умолчанию, magic перезаписывается |
| иное | первый старт, все поля по умолчанию |
Миграция нужна, чтобы уже прошитые приборы не потеряли настроенный webhook при
обновлении прошивки.
При чтении v2 значения дополнительно проверяются: пустое `device_name` и
выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию — иначе мусор
во флеше дал бы заведомо неверную выдержку.
Пустой `webhook_url` — легальное состояние. Пока URL не задан,
`sendPostWebhook()` сразу выходит и пишет в лог
`[HTTP] Webhook URL не задан — отправка пропущена`.
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM,
поэтому сброс Wi-Fi настройки из этой таблицы не затрагивает.
---
## 7. Веб-интерфейс
Сервер на порту `80`.
| Маршрут | Метод | Описание |
|---|---|---|
| `/` | `GET` | HTML-панель мониторинга |
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` |
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
### 7.1 `/set_config`
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»**
так частично заполненная форма не обнуляет остальные параметры. Запись в EEPROM
выполняется только если что-то реально изменилось.
| Параметр | Валидация | При отказе |
|---|---|---|
| `url` | 1…220 символов | поле не меняется |
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется |
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
Фильтрация `device` вместо экранирования выбрана осознанно: разрешённый набор
символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно
нигде.
### 7.2 Состояние реле
`rMain` и `rEmg` в `/status` — это **логическое** состояние реле из переменных
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
обратное действительности.
### 7.3 Автообновление
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при любом
изменении JSON. Поскольку состояние реле теперь входит в ответ, страница
обновляется и при срабатывании реле. Шрифты подгружаются с 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` |
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`.
---
## 9. Сброс настроек Wi-Fi
Физической кнопки сброса нет — она удалена осознанно. При сохранённых учётных
данных и пропавшей сети `wm.autoConnect()` не подключается и **сам** поднимает
портал `Pump_Control_Set`, поэтому для этого сценария кнопка не нужна.
Остаётся два способа:
* кнопка **«Сбросить Wi-Fi»** на веб-панели (`POST /reset_wifi`) — работает, пока
прибор в сети и панель доступна;
* выключить роутер и перезагрузить прибор — `autoConnect()` не найдёт сеть и
откроет портал.
Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо
перевести в другую сеть»: простая перезагрузка портал не откроет, так как
подключение проходит успешно.
---
## 10. Известные ограничения
* `setInsecure()` — TLS без проверки сертификата.
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета
(см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется
**до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только
обновление OLED, опрос кнопки и веб-панель.
* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются
и не повторяются — webhook теряется, флаги `mainAlarmSent` /
`emergencyActiveSent` выставляются независимо от результата POST.
Ретраи и очередь сознательно не реализованы: контроллер рассчитан на работу
и без отправки событий, автономность — штатный режим, а не отказ.
* **Пауза мониторинга при старте без сохранённой сети.** `wm.autoConnect()`
поднимает портал настройки и блокирует `setup()` в цикле ожидания до
истечения `setConfigPortalTimeout(120)`. Пока `setup()` не завершён, `loop()`
не выполняется: дребезг не считается, аварии не формируются, выходы не
переключаются (они остаются в безопасном `LOW`, выставленном до Wi-Fi).
В установке, где сеть не настраивается никогда, эти 2 минуты повторяются
при каждом включении питания. Принято как есть; при необходимости лечится
`wm.setConfigPortalBlocking(false)` + `wm.process()` в `loop()`с оговоркой,
что в неблокирующем режиме `setConfigPortalTimeout` не применяется и гасить
портал придётся своим таймером.
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
не считается и никак не сигнализируется.
* 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 бод.