# 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). Питание 5 В по microUSB или 3.3 В на пин `3V3`. **Флеш на целевой плате — 2 МБ.** Это важно: клоны Wemos D1 Mini встречаются с 1, 2 и 4 МБ, а размер, выбранный в IDE, обязан совпадать с физическим. Иначе области EEPROM и файловой системы окажутся за пределами микросхемы и настройки не сохранятся — см. раздел 6.1, там разобран ровно этот случай. Проверить фактический размер: `ESP.getFlashChipRealSize()`, прошивка печатает его в лог при старте строкой `[FLASH] по настройке IDE: ... физически: ...`. ### 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) * ArduinoJson (v7) Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`, `WiFiClientSecure`, `LittleFS`, `Wire`. Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600. **Flash Size: `2MB (FS:64KB OTA:~992KB)`** — под физические 2 МБ целевой платы. Именно этот вариант проверен на железе: LittleFS отдаёт 45 056 байт полезного объёма, чего для `settings.json` (несколько сотен байт) с запасом. Подойдёт и любой другой `2MB (FS:...)`, если понадобится больше места под файлы. Два требования к этой настройке, оба обязательные: * **Размер должен совпадать с физическим чипом.** Выбранные 4 МБ на плате с 2 МБ уводят и EEPROM, и файловую систему за границу микросхемы — настройки молча не сохраняются (раздел 6.1). * **Файловая система должна быть ненулевой.** При `FS:none` сохранять настройки некуда; прошивка скажет об этом в лог при старте и покажет предупреждение в веб-панели. Раскладка для 2 МБ (любой из вариантов `2MB (FS:...)`) кладёт файловую систему ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа. После смены `Flash Size` заливайте прошивку заново: меняется заголовок загрузчика и границы областей. Учтите, что **сохранённые учётные данные Wi-Fi при этом теряются** и прибор поднимет портал `Pump_Control_Set`. --- ## 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` на `settings.webhookUrl`, `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` берётся из настройки `settings.deviceName` (задаётся через веб-панель, по умолчанию `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. Хранение настроек (LittleFS) Настройки лежат в файловой системе LittleFS, в файле `/settings.json`: ```json {"webhook":"https://...","device":"Wemos_D1_Pump","alarm_delay":5,"emerg_delay":0} ``` | Поле структуры `AppSettings` | Ключ JSON | По умолчанию | |---|---|---| | `webhookUrl` (`char[221]`) | `webhook` | пусто | | `deviceName` (`char[32]`) | `device` | `Wemos_D1_Pump` | | `alarmDelaySec` (`uint16`) | `alarm_delay` | `5` | | `emergDelaySec` (`uint16`) | `emerg_delay` | `0` | Имена ключей вынесены в константы `KEY_*` и используются и при чтении, и при записи — рассинхронизация имён между load и save самый частый источник багов в таком коде, а через константу компилятор их разойтись не даст. Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без перезагрузки, и глобальные `String` фрагментировали бы кучу. ### 6.1 История: почему настройки не сохранялись Реальный случай, разобранный на этом проекте. Симптом: настройки и webhook пропадают после перезагрузки. **Корневая причина — несовпадение размера флеша.** В IDE было выбрано `4MB (FS:2MB OTA:~1019KB)`, тогда как физически на плате **2 МБ**: | Область | Смещение при настройке 4MB | Внутри чипа 2 МБ (`0x200000`)? | |---|---|---| | Сектор EEPROM | `0x3FB000` = 4 173 824 | нет, вдвое дальше конца | | Начало LittleFS | `0x400000` = 2 097 152 | нет, ровно за границей | `EEPROM.commit()` возвращал `false`, потому что `spi_flash_erase_sector()` обращался к несуществующему сектору. **Лечится настройкой IDE, а не кодом:** любой вариант `2MB (FS:...)` кладёт файловую систему ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа. С верной настройкой заработали бы оба механизма хранения. Что было проверено по пути и оказалось ни при чём: библиотека EEPROM, перекрытие адресов полей, стирание сектора со стороны `WiFiManager::resetSettings()` (вызывает только `WiFi.disconnect()`). **Почему всё-таки перешли на LittleFS**, раз причина была не в EEPROM: * добавление новой настройки — одна строка, без арифметики адресов и без версионирования раскладки с миграцией; * отсутствующий ключ в старом файле не ломает чтение; * содержимое читается глазами; * тот же механизм используется в соседних проектах — единообразие. То есть переход остаётся оправданным по своим качествам, но **проблему решила настройка `Flash Size`**, а не смена хранилища. Не перепутайте это, если столкнётесь с похожим симптомом на другой плате. **Диагностика при старте.** Прошивка печатает геометрию и сама указывает на несоответствие: ``` [FLASH] по настройке IDE: 4194304 Б, физически: 2097152 Б -> !!! НЕ СОВПАДАЕТ !!! [FLASH] файловая система: смещение 0x200000, размер 2093056 Б <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША! ``` ### 6.2 Отказоустойчивость Загрузка не падает на неполном или повреждённом файле. Порядок в `settingsBegin()`: 1. `settingsDefaults()` — заполнить структуру значениями по умолчанию; 2. `settingsLoad()` — перезаписать полями из JSON; отсутствующий или битый ключ **оставляет значение по умолчанию**; 3. если файла нет или JSON не разобрался — `settingsSave()` создаёт его заново. Благодаря пункту 2 файл, записанный прошлой версией прошивки, не ломается при добавлении новых полей — старый `/settings.json` просто не содержит нового ключа, и подставляется значение по умолчанию. `settingsClamp()` дополнительно приводит значения к допустимым: пустое `deviceName` и выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию. Вызывается и после чтения, и перед записью. Если ФС не смонтировалась, выполняется `LittleFS.format()` и повторная попытка. При окончательной неудаче взводится `settingsFsReady = false`: прибор продолжает работать на значениях по умолчанию, но **веб-панель показывает предупреждение**, что настройки не переживут перезагрузку. Молчаливой потери настроек больше нет. ### 6.3 Ресурс флеша Запись происходит только по факту изменения — из обработчика `/set_config`, и только если хотя бы одно поле реально изменилось. Периодической записи нет и добавлять её не следует. ### 6.4 Как добавить новую настройку Синхронно правятся четыре места: 1. константа `*_DEFAULT` рядом с остальными значениями по умолчанию; 2. поле в `struct AppSettings` и строка в `settingsDefaults()`; 3. константа `KEY_*`, строка в `settingsLoad()` и строка в `settingsSave()`; 4. поле формы в `getHTML()` и его разбор в обработчике `/set_config`. Старый `/settings.json` без нового ключа не сломается — подставится значение по умолчанию. ### 6.5 Что не хранится здесь Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, поэтому сброс Wi-Fi настройки из `/settings.json` не затрагивает, и наоборот. Пустой `webhookUrl` — легальное состояние. Пока URL не задан, `sendPostWebhook()` сразу выходит и пишет в лог `[HTTP] Webhook URL не задан — отправка пропущена`. --- ## 7. Веб-интерфейс Сервер на порту `80`. | Маршрут | Метод | Описание | |---|---|---| | `/` | `GET` | Статическая HTML-панель из PROGMEM | | `/status` | `GET` | Живое состояние, JSON | | `/config` | `GET` | Настройки и адрес в сети, JSON | | `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться | | `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` | ### 7.1 Почему страница статическая Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около **14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`, а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.** Побочный, но важный эффект: прежняя панель при любом изменении состояния делала `location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не трогается. Ответы: ```json /status {"p1":1,"p2":0,"pE":0,"rMain":0,"rEmg":0,"rdy":1} /config {"device":"Nasosnaya_1","alarm":5,"emerg":0, "host":"n8n.example.com","fs":1,"ip":"192.168.1.128"} ``` `/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит секретный токен, и выводить его на экран, который видно всем в помещении, незачем. ### 7.2 Логика статуса и цвета Цвета взяты по конвенции сигнальных ламп, а не по вкусу: | Состояние | Цвет | Заголовок | Условие | |---|---|---|---| | Запуск | серый | «Запуск» | `rdy = 0` | | Норма | зелёный | «Подача есть» | работает хотя бы один основной | | Внимание | **жёлтый** | «Работает резерв» | работает резерв, основные в порядке | | Авария | красный | «Насосы стоят» | оба основных стоят | Резерв намеренно **жёлтый, а не красный**: работающий резерв — предупреждение, система ещё справляется. Красный оставлен для случая, когда основной подачи нет. При аварии плашка медленно пульсирует; пульсация отключается при `prefers-reduced-motion`. ### 7.3 Состояние реле `rMain` и `rEmg` — **логическое** состояние из переменных `relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Выходы инверсные, и чтение уровня показало бы на панели обратное действительности. Лампа выхода «Авария» — **красная**. В первой версии панели она была зелёной: стиль «включено» был общим для всех ламп, и сработавшая аварийная сигнализация подсвечивалась цветом «всё хорошо». ### 7.4 Никаких внешних запросов Веб-шрифты не подключаются. В первой версии был `@import` с Google Fonts, но сеть на объекте не разрешает внешние имена (раздел 5.1), поэтому шрифты там не загружались никогда — нарисованный макет на месте не появлялся. Сейчас используются системные стеки, а характер задан контрастом кеглей: заголовок статуса `clamp(28px, 8.5vw, 44px)` против меток 11–12 px. ### 7.5 Подпись состояния во вкладке Заголовок вкладки и favicon меняют цвет вместе со статусом: `Насосы стоят · Nasosnaya_1` с красным кружком. Вкладка, забытая в фоне на телефоне, продолжает сигналить, не требуя переключения на неё. ### 7.6 Доступ Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить адрес уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации. Контроллер рассчитан на изолированный технологический сегмент. --- ## 8. OLED Обновление раз в секунду (`DISPLAY_INTERVAL`). Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери. Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не менялся и ничего не сообщал. ``` NO SUPPLY <- кегль 2, до 10 символов ──────────────────────── P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх ALARM:ON BACKUP:ON <- состояние выходов ──────────────────────── 192.168.1.128 ``` | Условие | Слово | |---|---| | `!systemReady` | `STARTING` | | оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) | | оба основных стоят, резерв работает | `BACKUP ON` (мигает) | | работает резерв, основные в порядке | `BACKUP RUN` | | иначе | `SUPPLY OK` | Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а тащить её во флеш ради четырёх слов не стоит. Состояние обоих реле теперь выводится — освободились две строки после объединения датчиков в одну. --- ## 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 хранится в `/settings.json` в открытом виде и отдаётся веб-панелью всем, кто может её открыть. Если 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 бод.