From 1cdcaa9e99118184d6ec6f52226e62af8a4e177d Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 13:39:47 +0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=20=D0=BD=D0=B0=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20v8.2=20+=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошивка Wemos D1 Mini для мониторинга трёх насосов: OLED, веб-панель, webhook-уведомления, сброс Wi-Fi кнопкой. Адрес webhook задаётся через веб-панель и хранится в EEPROM; в исходнике не хранится, так как содержит секретный токен. Добавлены README.md (техдокументация) и MANUAL.md (руководство). Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/.gitignore | 9 + pump_controller_8_2_OLED_DONE/MANUAL.md | 211 ++++++ pump_controller_8_2_OLED_DONE/README.md | 250 +++++++ .../pump_controller_8_2_OLED_DONE.ino | 635 ++++++++++++++++++ 4 files changed, 1105 insertions(+) create mode 100644 pump_controller_8_2_OLED_DONE/.gitignore create mode 100644 pump_controller_8_2_OLED_DONE/MANUAL.md create mode 100644 pump_controller_8_2_OLED_DONE/README.md create mode 100644 pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino diff --git a/pump_controller_8_2_OLED_DONE/.gitignore b/pump_controller_8_2_OLED_DONE/.gitignore new file mode 100644 index 0000000..e28c988 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/.gitignore @@ -0,0 +1,9 @@ +build/ +.build/ +*.bin +*.elf +*.hex +*.map +.vscode/ +.DS_Store +Thumbs.db diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md new file mode 100644 index 0000000..fdb43c1 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -0,0 +1,211 @@ +# Контроллер насосов v8.2 — руководство пользователя + +Документ для монтажника и обслуживающего персонала. +Техническое описание кода — в [README.md](README.md). + +--- + +## 1. Что делает прибор + +Контроллер следит за тремя насосами: + +* **Насос 1** и **Насос 2** — основные; +* **Резерв** — аварийный (экстренный) насос. + +Прибор: + +1. показывает состояние на экране; +2. включает выход «Авария», если **оба** основных насоса остановлены; +3. включает выход «Резерв», когда запускается резервный насос; +4. отправляет уведомления на сервер (webhook) через интернет; +5. показывает панель мониторинга в браузере в локальной сети. + +--- + +## 2. Подключение + +### 2.1 Питание + +5 В через microUSB (блок питания не менее 500 мА). + +### 2.2 Датчики насосов + +К каждому входу подключается **сухой контакт** — свободный контакт пускателя +насоса или датчика потока. + +| Клемма платы | Что подключить | +|---|---| +| `D5` и `GND` | Контакт насоса 1 | +| `D6` и `GND` | Контакт насоса 2 | +| `D7` и `GND` | Контакт резервного насоса | + +**Контакт замкнут = насос работает.** Полярность не важна, внешние резисторы +и питание на контакты подавать не нужно. + +### 2.3 Выходы аварии + +| Клемма | Когда `HIGH` (3.3 В) | +|---|---| +| `D8` | Авария: оба основных насоса стоят | +| `D0` | Работает резервный насос | + +Выходы логические, 3.3 В, ток до ~12 мА. **Реле, сирену или лампу напрямую +подключать нельзя** — только через транзисторный ключ или релейный модуль +с оптронной развязкой. + +### 2.4 Кнопка сброса Wi-Fi + +Обычная кнопка без фиксации между `D3` и `GND`. + +> Кнопку нельзя держать нажатой в момент включения питания — плата уйдёт в +> служебный режим и не запустится. + +### 2.5 Экран + +OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`. + +--- + +## 3. Первое включение + +1. Подайте питание. На экране появится `PUMP CTRL v8 / Starting...`. +2. Прибор не найдёт сохранённой сети и поднимет свою точку доступа: + + ``` + Имя сети: Pump_Control_Set + ``` + +3. Подключитесь к ней с телефона или ноутбука (пароль не требуется). +4. Откройте в браузере `http://192.168.4.1` — откроется страница настройки. +5. Выберите вашу Wi-Fi-сеть, введите пароль, нажмите **Save**. +6. Прибор перезагрузится и покажет на экране полученный IP-адрес. + **Запишите этот адрес** — по нему открывается панель мониторинга. + +Если за 2 минуты сеть не настроена, прибор переходит в автономный режим: +экран, выходы аварии и логика работают, webhook и веб-панель — нет. + +--- + +## 4. Экран + +``` + PUMP CONTROLLER v8 +──────────────────────── +PUMP 1 : RUNNING +PUMP 2 : STOPPED +BACKUP : STANDBY +──────────────────────── +192.168.1.42 +STATUS: OK +``` + +| Надпись | Значение | +|---|---| +| `PUMP 1/2 : RUNNING` | Насос работает | +| `PUMP 1/2 : STOPPED` | Насос остановлен | +| `BACKUP : ACTIVE` | Резервный насос запущен | +| `BACKUP : STANDBY` | Резерв в ожидании | +| `WiFi: NO CONNECTION` | Нет связи с сетью | +| `INIT...` | Идёт запуск (первые ~4 секунды) | +| `STATUS: OK` | Норма | +| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание | +| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** | + +--- + +## 5. Панель в браузере + +Откройте `http://` в любом браузере в той же сети. + +На странице: + +* три карточки насосов с индикаторами; +* панель состояния системы (`SYS_OK` или `CRITICAL`); +* кнопка **Сбросить Wi-Fi**; +* поле **Webhook URL** для смены адреса уведомлений. + +Страница обновляется автоматически при изменении состояния насосов. + +### Адрес уведомлений + +**На новом приборе адрес не задан** — панель покажет +`не задан - уведомления отключены`, и сообщения на сервер отправляться не будут. +Введите адрес в поле под заголовком `Webhook URL` и нажмите **Сохранить**. + +Адрес сохраняется в память прибора и переживает отключение питания и сброс +Wi-Fi. Максимальная длина — 220 символов. + +--- + +## 6. Уведомления на сервер + +При изменении ситуации прибор отправляет сообщение на настроенный адрес: + +| Событие | Что означает | +|---|---| +| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят | +| `MAIN PUMPS` / `OK` | Работа восстановлена | +| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос | +| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился | + +Уведомления отправляются один раз на каждое изменение, а не повторяются. +Если в момент события связи не было — уведомление **не будет** отправлено +позже, оно теряется. + +--- + +## 7. Сброс настроек Wi-Fi + +Нужен при смене роутера или пароля сети. + +**Способ 1 — кнопкой:** +нажмите и держите кнопку на `D3`. На экране появится полоса и обратный отсчёт +от 3 секунд. Дождитесь конца — прибор перезагрузится и снова поднимет точку +доступа `Pump_Control_Set`. Отпустите кнопку раньше — ничего не произойдёт. + +**Способ 2 — через браузер:** +на панели мониторинга нажмите **Сбросить Wi-Fi**. + +Webhook URL при сбросе Wi-Fi **не стирается**. + +--- + +## 8. Задержки срабатывания + +Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и +остановки: + +| Событие | Задержка | +|---|---| +| Фиксация нового состояния датчика | 3 секунды | +| Авария основных насосов (после фиксации) | ещё 5 секунд | +| **Итого до сигнала аварии** | **~8 секунд** | +| Реакция на резервный насос | ~3 секунды | + +--- + +## 9. Диагностика + +| Симптом | Причина / что делать | +|---|---| +| Экран не горит | Проверьте питание и провода SDA/SCL. Если адрес дисплея `0x3D`, требуется правка прошивки (константа `OLED_ADDR`) | +| Экран горит, но насосы всегда `STOPPED` | Контакты датчиков не замыкаются на `GND`; проверьте общий провод | +| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя | +| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) | +| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора | +| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод), строка `[HTTP] Код:` | +| Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой | + +Подробный лог работы выводится в USB-порт: скорость **115200 бод** +(Arduino IDE → Инструменты → Монитор порта). + +--- + +## 10. Безопасность + +Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может +открыть панель, сбросить Wi-Fi и изменить адрес уведомлений. Устанавливайте +прибор в закрытом технологическом сегменте сети, не в гостевом Wi-Fi. + +Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты. +Он не заменяет штатную автоматику защиты насосной станции. diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md new file mode 100644 index 0000000..19eb4cb --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -0,0 +1,250 @@ +# 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. + +Отправка синхронная и блокирующая: на время POST (до нескольких секунд при +недоступном сервере) основной цикл приостанавливается. Дисплей в этот момент +не обновляется. + +--- + +## 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 отправляется синхронно и блокирует цикл; при недоступном сервере + реакция контроллера замедляется на время таймаута. +* События, произошедшие без Wi-Fi, не буферизуются — webhook просто теряется + (в лог пишется `[HTTP] Нет Wi-Fi`). +* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией + не считается и никак не сигнализируется. +* 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 бод. 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 new file mode 100644 index 0000000..316cfa6 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino @@ -0,0 +1,635 @@ +/* + ============================================================ + Контроллер насосов v8 — Wemos D1 Mini + ============================================================ + Распиновка: + D5 — IN_PUMP_1 (датчик, INPUT_PULLUP, LOW = работает) + D6 — IN_PUMP_2 (датчик, INPUT_PULLUP, LOW = работает) + D7 — IN_PUMP_EMERGENCY (датчик, INPUT_PULLUP, LOW = работает) + D8 — OUT_ALARM_MAIN (выход, HIGH = авария) + D0 — OUT_ALARM_EMERGENCY (выход, HIGH = авария; активен HIGH несмотря на pull-up к VCC) + BTN_WIFI_RESET = D3; // Кнопка сброса WiFi (INPUT_PULLUP, GND при нажатии) + D1 — OLED SDA (I2C) + D2 — OLED SCL (I2C) + + Библиотеки (установить через Library Manager): + - Adafruit SSD1306 + - Adafruit GFX Library + - WiFiManager (tzapu) + - ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient (часть ESP8266 core) + ============================================================ +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --- OLED --- +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 64 +#define OLED_RESET -1 // нет отдельного RESET +#define OLED_ADDR 0x3C // обычный адрес SSD1306; попробуй 0x3D если дисплей не найден + +Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); +bool oledOK = false; + +// --- КОНФИГУРАЦИЯ --- +const char* config_ap_name = "Pump_Control_Set"; +// Адрес webhook задаётся через веб-панель (/set_webhook) и хранится в EEPROM. +// В исходнике намеренно пусто: URL содержит секретный токен. +const char* webhook_url_default = ""; + +const int EEPROM_SIZE = 256; +const int EEPROM_MAGIC_ADDR = 0; +const uint16_t EEPROM_MAGIC = 0xA55A; +const int EEPROM_URL_ADDR = 2; +const int WEBHOOK_MAX_LEN = 220; + +char webhook_url[WEBHOOK_MAX_LEN + 1]; + +// --- ПИНЫ --- +const int IN_PUMP_1 = D5; +const int IN_PUMP_2 = D6; +const int IN_PUMP_EMERGENCY = D7; +const int OUT_ALARM_MAIN = D8; // HIGH = авария +const int OUT_ALARM_EMERGENCY = D0; // HIGH = авария (D0 pull-up к VCC — при старте не тянет HIGH) +const int BTN_WIFI_RESET = D3; // Кнопка сброса WiFi (INPUT_PULLUP, GND при нажатии) + +// --- КНОПКА СБРОСА WiFi --- +const unsigned long BTN_HOLD_MS = 3000; // Удержание 3 сек для сброса +unsigned long btnPressStart = 0; +bool btnHolding = false; + +ESP8266WebServer server(80); + +// --- СТРУКТУРА НАСОСА --- +typedef struct { + int pin; + String name; + bool stableState; + bool lastReading; + unsigned long lastDebounceTime; +} Pump; + +Pump p1 = {IN_PUMP_1, "PUMP_1", false, true, 0}; +Pump p2 = {IN_PUMP_2, "PUMP_2", false, true, 0}; +Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0}; + +const unsigned long debounceDelay = 3000; +const unsigned long alarmDelay = 5000; + +bool mainAlarmSent = false; +bool emergencyActiveSent = false; +bool systemReady = false; +unsigned long mainAlarmStartTime = 0; +bool mainAlarmPending = false; + +// Для обновления дисплея не чаще раза в секунду +unsigned long lastDisplayUpdate = 0; +const unsigned long DISPLAY_INTERVAL = 1000; + +// ============================================================ +// EEPROM +// ============================================================ +void eepromWriteUrl(const char* url) { + EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC); + for (int i = 0; i < WEBHOOK_MAX_LEN; i++) { + EEPROM.write(EEPROM_URL_ADDR + i, i < (int)strlen(url) ? url[i] : 0); + } + EEPROM.commit(); + Serial.println("[EEPROM] URL сохранён: " + String(url)); +} + +void eepromReadUrl() { + uint16_t magic; + EEPROM.get(EEPROM_MAGIC_ADDR, magic); + if (magic == EEPROM_MAGIC) { + for (int i = 0; i < WEBHOOK_MAX_LEN; i++) { + webhook_url[i] = EEPROM.read(EEPROM_URL_ADDR + i); + } + webhook_url[WEBHOOK_MAX_LEN] = '\0'; + Serial.println("[EEPROM] URL загружен: " + String(webhook_url)); + } else { + strncpy(webhook_url, webhook_url_default, WEBHOOK_MAX_LEN); + webhook_url[WEBHOOK_MAX_LEN] = '\0'; + eepromWriteUrl(webhook_url); + Serial.println("[EEPROM] Первый старт. URL по умолчанию сохранён."); + } +} + +// ============================================================ +// OLED — отображение состояния +// ============================================================ +/* + Макет экрана 128x64 (8 строк по 8px при шрифте 1): + + ┌──────────────────────────┐ + │ PUMP CONTROLLER │ строка 0 — заголовок + │ ────────────────────── │ строка 1 — разделитель + │ НАСОС 1: РАБОТАЕТ │ строка 2 + │ НАСОС 2: СТОИТ │ строка 3 + │ РЕЗЕРВ : СТОИТ │ строка 4 + │ ────────────────────── │ строка 5 — разделитель + │ WiFi: 192.168.1.42 │ строка 6 + │ СТАТУС: НОРМА │ строка 7 + └──────────────────────────┘ + + При аварии строка 7 мигает "!! АВАРИЯ !!" +*/ + +// ============================================================ +// Кнопка сброса WiFi (удержание BTN_HOLD_MS мс) +// ============================================================ +void handleResetButton() { + bool pressed = (digitalRead(BTN_WIFI_RESET) == LOW); + + if (pressed) { + if (!btnHolding) { + btnHolding = true; + btnPressStart = millis(); + } else { + unsigned long held = millis() - btnPressStart; + + // Показываем прогресс-бар на дисплее + if (oledOK && held < BTN_HOLD_MS) { + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); + display.setCursor(10, 10); + display.print(F("Hold to reset WiFi")); + // Прогресс-бар + int barW = (int)((held * 108UL) / BTN_HOLD_MS); + display.drawRect(10, 28, 108, 12, SSD1306_WHITE); + display.fillRect(10, 28, barW, 12, SSD1306_WHITE); + // Счётчик секунд + display.setCursor(10, 46); + display.print((BTN_HOLD_MS - held) / 1000 + 1); + display.print(F(" sec...")); + display.display(); + } + + // Порог удержания достигнут — сброс + if (held >= BTN_HOLD_MS) { + Serial.println(F("[BTN] WiFi reset by button!")); + if (oledOK) { + display.clearDisplay(); + display.setCursor(20, 20); + display.print(F("WiFi RESET!")); + display.setCursor(10, 36); + display.print(F("Rebooting...")); + display.display(); + } + delay(1500); + WiFiManager wm; + wm.resetSettings(); + ESP.restart(); + } + } + } else { + // Кнопка отпущена до порога — сброс состояния + if (btnHolding) { + btnHolding = false; + lastDisplayUpdate = 0; // принудительное обновление дисплея + } + } +} + +void updateDisplay() { + if (!oledOK) return; + + display.clearDisplay(); + display.setTextColor(SSD1306_WHITE); + + // --- Заголовок --- + display.setTextSize(1); + display.setCursor(0, 0); + display.print(F(" PUMP CONTROLLER v8")); + + // --- Разделитель --- + display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE); + + // --- Состояния насосов --- + display.setCursor(0, 14); + display.print(F("PUMP 1 : ")); + display.print(p1.stableState ? F("RUNNING") : F("STOPPED")); + + display.setCursor(0, 24); + display.print(F("PUMP 2 : ")); + display.print(p2.stableState ? F("RUNNING") : F("STOPPED")); + + display.setCursor(0, 34); + display.print(F("BACKUP : ")); + display.print(pE.stableState ? F("ACTIVE ") : F("STANDBY")); + + // --- Разделитель --- + display.drawFastHLine(0, 44, SCREEN_WIDTH, SSD1306_WHITE); + + // --- IP / статус WiFi --- + display.setCursor(0, 47); + if (WiFi.status() == WL_CONNECTED) { + display.print(WiFi.localIP().toString()); + } else { + display.print(F("WiFi: NO CONNECTION")); + } + + // --- Строка статуса --- + bool alarm = (!p1.stableState && !p2.stableState); + bool emergency = pE.stableState; + + display.setCursor(0, 56); + if (!systemReady) { + display.print(F("INIT...")); + } else if (alarm) { + // Мигание каждые 500 мс + if ((millis() / 500) % 2 == 0) { + display.fillRect(0, 54, SCREEN_WIDTH, 10, SSD1306_WHITE); + display.setTextColor(SSD1306_BLACK); + } + display.print(F("!! ALARM: NO PUMPS !!")); + display.setTextColor(SSD1306_WHITE); + } else if (emergency) { + display.print(F("WARN: BACKUP RUNNING")); + } else { + display.print(F("STATUS: OK")); + } + + display.display(); +} + +// ============================================================ +// HTTPS POST +// ============================================================ +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"); + } +} + +// ============================================================ +// Дребезг + обновление стабильного состояния +// ============================================================ +void updatePumpState(Pump &p) { + bool currentReading = digitalRead(p.pin); + if (currentReading != p.lastReading) { + p.lastDebounceTime = millis(); + } + if ((millis() - p.lastDebounceTime) > debounceDelay) { + bool logicalState = (currentReading == LOW); + if (logicalState != p.stableState) { + p.stableState = logicalState; + Serial.printf("[SENSOR] %s -> %s\n", p.name.c_str(), p.stableState ? "РАБОТАЕТ" : "СТОИТ"); + } + } + p.lastReading = currentReading; +} + +// ============================================================ +// HTML интерфейс (без изменений логики, только IP-адрес актуален) +// ============================================================ +String getHTML() { + bool isAlarm = (!p1.stableState && !p2.stableState); + bool isEmergency = pE.stableState; + + String html = F(""); + html += ""; + html += F("" + "Мониторинг Насосов" + "
"); + + html += F("
" + "
SYS · PUMP_CTRL
" + "
Мониторинг
Насосов
" + "
LIVE · AUTO
" + "
--:--:--
" + "
"); + + html += "
"; + + // Насос 1 + html += "
"; + html += F("
Насос
" + "" + "" + "" + "" + "" + "" + "" + ""); + html += "
Основной 1
" + String(p1.stableState ? "Работает" : "Стоит") + "
"; + + // Насос 2 + html += "
"; + html += F("
Насос
" + "" + "" + "" + "" + "" + "" + "" + ""); + html += "
Основной 2
" + String(p2.stableState ? "Работает" : "Стоит") + "
"; + + // Резерв + html += "
"; + html += F("
Резерв
" + "" + "" + "" + "" + ""); + html += "
Резерв
" + String(pE.stableState ? "Активен" : "Ожидание") + "
"; + + html += "
"; + + if (isAlarm) { + html += "
CRITICAL · 0xFF
Нет подачи!
"; + } else { + html += "
SYS_OK · 0x00
Система: норма
"; + } + + html += "
Управление
"; + html += "
Webhook URL
" + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + + if (pE.stableState) { + html += "
WARN · BACKUP
Работает резерв!
"; + } + + html += F("
"); + + return html; +} + +// ============================================================ +// SETUP +// ============================================================ +void setup() { + Serial.begin(115200); + delay(500); + Serial.println(F("\n\n--- СТАРТ КОНТРОЛЛЕРА v8 ---")); + + // I2C: SDA=D1, SCL=D2 (по умолчанию для Wemos D1 Mini) + Wire.begin(D1, D2); + + // Инит OLED + if (display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) { + oledOK = true; + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); + display.setCursor(20, 20); + display.print(F("PUMP CTRL v8")); + display.setCursor(20, 34); + display.print(F("Starting...")); + display.display(); + Serial.println(F("[OLED] Дисплей OK")); + } else { + Serial.println(F("[OLED] Дисплей не найден! Проверь адрес (0x3C/0x3D) и провода.")); + } + + EEPROM.begin(EEPROM_SIZE); + eepromReadUrl(); + + pinMode(p1.pin, INPUT_PULLUP); + pinMode(p2.pin, INPUT_PULLUP); + pinMode(pE.pin, INPUT_PULLUP); + pinMode(BTN_WIFI_RESET, INPUT_PULLUP); + pinMode(OUT_ALARM_MAIN, OUTPUT); + pinMode(OUT_ALARM_EMERGENCY, OUTPUT); + digitalWrite(OUT_ALARM_MAIN, LOW); + digitalWrite(OUT_ALARM_EMERGENCY, LOW); + + Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi...")); + + // Web-сервер + server.on("/", HTTP_GET, []() { + server.send(200, "text/html", getHTML()); + }); + server.on("/status", HTTP_GET, []() { + String json = "{"; + json += "\"p1\":" + String(p1.stableState ? "1" : "0") + ","; + json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ","; + json += "\"pE\":" + String(pE.stableState ? "1" : "0"); + json += "}"; + server.send(200, "application/json", json); + }); + server.on("/reset_wifi", HTTP_POST, []() { + server.send(200, "text/plain", "WiFi Reset. Rebooting..."); + delay(2000); + WiFiManager wm; wm.resetSettings(); ESP.restart(); + }); + server.on("/set_webhook", HTTP_POST, []() { + if (server.hasArg("url") && server.arg("url").length() > 0) { + String newUrl = server.arg("url"); newUrl.trim(); + if (newUrl.length() <= WEBHOOK_MAX_LEN) { + newUrl.toCharArray(webhook_url, WEBHOOK_MAX_LEN + 1); + eepromWriteUrl(webhook_url); + server.sendHeader("Location", "/"); server.send(303); + } + } else { + server.sendHeader("Location", "/"); server.send(303); + } + }); + + // WiFiManager с заставкой на OLED + if (oledOK) { + display.clearDisplay(); + display.setCursor(0, 10); + display.print(F("WiFi: connecting...")); + display.setCursor(0, 24); + display.print(F("AP: ")); + display.print(config_ap_name); + display.setCursor(0, 38); + display.print(F("Connect & open:")); + display.setCursor(0, 48); + display.print(F("192.168.4.1")); + display.display(); + } + + WiFiManager wm; + wm.setConfigPortalTimeout(120); + if (wm.autoConnect(config_ap_name)) { + server.begin(); + Serial.println("[WIFI] Подключено. IP: " + WiFi.localIP().toString()); + if (oledOK) { + display.clearDisplay(); + display.setCursor(0, 16); + display.print(F("WiFi: CONNECTED")); + display.setCursor(0, 30); + display.print(F("IP: ")); + display.print(WiFi.localIP().toString()); + display.display(); + delay(2000); + } + } else { + Serial.println(F("[WIFI] Работа в автономном режиме.")); + if (oledOK) { + display.clearDisplay(); + display.setCursor(0, 16); + display.print(F("WiFi: NO CONNECTION")); + display.setCursor(0, 30); + display.print(F("Offline mode active")); + display.display(); + delay(2000); + } + } + + Serial.println(F("[SYSTEM] Инициализация завершена.")); +} + +// ============================================================ +// LOOP +// ============================================================ +void loop() { + server.handleClient(); + handleResetButton(); + + updatePumpState(p1); + updatePumpState(p2); + updatePumpState(pE); + + if (!systemReady) { + if (millis() > debounceDelay + 500) { + systemReady = true; + Serial.println(F("[SYSTEM] Автоматика готова.")); + } + // Обновляем дисплей даже во время инита + if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) { + updateDisplay(); + lastDisplayUpdate = millis(); + } + return; + } + + // --- Основная авария --- + bool bothStopped = (!p1.stableState && !p2.stableState); + if (bothStopped) { + if (!mainAlarmPending) { + mainAlarmPending = true; + mainAlarmStartTime = millis(); + } else if (millis() - mainAlarmStartTime >= alarmDelay) { + digitalWrite(OUT_ALARM_MAIN, HIGH); + if (!mainAlarmSent) { + Serial.println(F("[ALERT] Авария основных насосов")); + sendPostWebhook("MAIN PUMPS", "ALARM"); + mainAlarmSent = true; + } + } + } else { + digitalWrite(OUT_ALARM_MAIN, LOW); + mainAlarmPending = false; + if (mainAlarmSent) { + Serial.println(F("[INFO] Восстановление: основные насосы OK")); + sendPostWebhook("MAIN PUMPS", "OK"); + mainAlarmSent = false; + } + } + + // --- Резервный насос --- + if (pE.stableState && !emergencyActiveSent) { + digitalWrite(OUT_ALARM_EMERGENCY, HIGH); + Serial.println(F("[WARN] Запущен экстренный насос")); + sendPostWebhook("EMERGENCY PUMP", "STARTED"); + emergencyActiveSent = true; + } else if (!pE.stableState && emergencyActiveSent) { + digitalWrite(OUT_ALARM_EMERGENCY, LOW); + Serial.println(F("[INFO] Резерв остановлен")); + sendPostWebhook("EMERGENCY PUMP", "STOPPED"); + emergencyActiveSent = false; + } + + // --- Обновление дисплея раз в секунду --- + if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) { + updateDisplay(); + lastDisplayUpdate = millis(); + } + + delay(10); +}