Initial commit: контроллер насосов v8.2 + документация
Прошивка Wemos D1 Mini для мониторинга трёх насосов: OLED, веб-панель, webhook-уведомления, сброс Wi-Fi кнопкой. Адрес webhook задаётся через веб-панель и хранится в EEPROM; в исходнике не хранится, так как содержит секретный токен. Добавлены README.md (техдокументация) и MANUAL.md (руководство). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
build/
|
||||||
|
.build/
|
||||||
|
*.bin
|
||||||
|
*.elf
|
||||||
|
*.hex
|
||||||
|
*.map
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -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://<IP-адрес прибора>` в любом браузере в той же сети.
|
||||||
|
|
||||||
|
На странице:
|
||||||
|
|
||||||
|
* три карточки насосов с индикаторами;
|
||||||
|
* панель состояния системы (`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.
|
||||||
|
|
||||||
|
Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты.
|
||||||
|
Он не заменяет штатную автоматику защиты насосной станции.
|
||||||
@@ -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 бод.
|
||||||
@@ -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 <ESP8266WiFi.h>
|
||||||
|
#include <ESP8266WebServer.h>
|
||||||
|
#include <ESP8266HTTPClient.h>
|
||||||
|
#include <WiFiClientSecure.h>
|
||||||
|
#include <WiFiManager.h>
|
||||||
|
#include <EEPROM.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_SSD1306.h>
|
||||||
|
|
||||||
|
// --- 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("<!DOCTYPE html><html lang='ru'><head><meta charset='UTF-8'>");
|
||||||
|
html += "<link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><circle cx=%2250%22 cy=%2250%22 r=%2245%22 fill=%22%23070b12%22 stroke=%22%232ea043%22 stroke-width=%228%22/><circle cx=%2250%22 cy=%2250%22 r=%2220%22 fill=%22none%22 stroke=%22%232ea043%22 stroke-width=%226%22/><line x1=%2250%22 y1=%225%22 x2=%2250%22 y2=%2222%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2250%22 y1=%2278%22 x2=%2250%22 y2=%2295%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%225%22 y1=%2250%22 x2=%2222%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2278%22 y1=%2250%22 x2=%2295%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/></svg>'>";
|
||||||
|
html += F("<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
|
"<title>Мониторинг Насосов</title>"
|
||||||
|
"<style>"
|
||||||
|
"@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Unbounded:wght@400;700&display=swap');"
|
||||||
|
"*{box-sizing:border-box;margin:0;padding:0}"
|
||||||
|
"body{font-family:'JetBrains Mono',monospace;background:#070b12;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}"
|
||||||
|
".dash{background:#070b12;border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:28px 24px;width:100%;max-width:520px;position:relative;overflow:hidden}"
|
||||||
|
".dash::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 80% 20%,rgba(20,80,160,0.18) 0%,transparent 70%),radial-gradient(ellipse 40% 30% at 10% 80%,rgba(10,120,80,0.12) 0%,transparent 60%);pointer-events:none}"
|
||||||
|
".grid-bg{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.025) 1px,transparent 1px);background-size:40px 40px;pointer-events:none}"
|
||||||
|
".header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px;position:relative;z-index:1}"
|
||||||
|
".sys-label{font-family:'Unbounded',monospace;font-size:9px;letter-spacing:0.2em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:4px}"
|
||||||
|
".title{font-family:'Unbounded',monospace;font-size:17px;font-weight:700;color:#e8edf5;letter-spacing:0.02em}"
|
||||||
|
".refresh-badge{display:flex;align-items:center;gap:6px;font-size:10px;color:rgba(255,255,255,0.3);letter-spacing:0.08em;margin-top:6px}"
|
||||||
|
".pulse-dot{width:6px;height:6px;border-radius:50%;background:#2ea043;animation:livePulse 2s ease-in-out infinite}"
|
||||||
|
"@keyframes livePulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}}"
|
||||||
|
".time-block{text-align:right}.time-val{font-size:20px;font-weight:700;color:rgba(255,255,255,0.55);letter-spacing:0.05em}.date-val{font-size:10px;color:rgba(255,255,255,0.2);letter-spacing:0.1em;margin-top:2px}"
|
||||||
|
".pumps-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;position:relative;z-index:1}"
|
||||||
|
".pump-card{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:14px 12px;position:relative;overflow:hidden}"
|
||||||
|
".pump-card.active{border-color:rgba(46,160,67,0.4);background:rgba(46,160,67,0.05)}"
|
||||||
|
".pump-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,#2ea043,transparent);animation:scanLine 3s linear infinite}"
|
||||||
|
".pump-card.emergency.active{border-color:rgba(218,54,51,0.5);background:rgba(218,54,51,0.06)}"
|
||||||
|
".pump-card.emergency.active::after{background:linear-gradient(90deg,transparent,#da3633,transparent)}"
|
||||||
|
"@keyframes scanLine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}"
|
||||||
|
".pump-type{font-size:8px;letter-spacing:0.18em;color:rgba(255,255,255,0.22);text-transform:uppercase;margin-bottom:8px}.pump-type.emerg{color:rgba(218,54,51,0.45)}"
|
||||||
|
".pump-icon{width:32px;height:32px;margin-bottom:8px}.pump-name{font-size:11px;font-weight:500;color:#c9d1d9;margin-bottom:10px;line-height:1.4}"
|
||||||
|
".pump-status{display:flex;align-items:center;gap:6px}.led{width:7px;height:7px;border-radius:50%;flex-shrink:0}"
|
||||||
|
".led.on{background:#2ea043;box-shadow:0 0 6px rgba(46,160,67,0.8);animation:ledGlow 2s ease-in-out infinite}.led.on.emerg{background:#da3633;box-shadow:0 0 6px rgba(218,54,51,0.8)}.led.off{background:rgba(255,255,255,0.12)}"
|
||||||
|
"@keyframes ledGlow{0%,100%{opacity:1}50%{opacity:0.55}}.stext{font-size:9px;letter-spacing:0.1em;text-transform:uppercase;font-weight:500}"
|
||||||
|
".stext.on{color:#2ea043}.stext.on.emerg{color:#da3633}.stext.off{color:rgba(255,255,255,0.2)}"
|
||||||
|
".meter{margin-top:10px;height:3px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}.mfill{height:100%;border-radius:2px;background:#2ea043;transition:width 1s ease}.mfill.emerg{background:#da3633}"
|
||||||
|
".bottom-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;position:relative;z-index:1}"
|
||||||
|
".spanel{border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}.spanel.ok{background:rgba(35,134,54,0.12);border:1px solid rgba(46,160,67,0.3)}"
|
||||||
|
".spanel.crit{background:rgba(248,81,73,0.08);border:1px solid rgba(248,81,73,0.3)}"
|
||||||
|
".sicon{width:26px;height:26px;flex-shrink:0}"
|
||||||
|
".scode{font-size:8px;letter-spacing:0.15em;text-transform:uppercase;margin-bottom:3px}.scode.ok{color:#2ea043}.scode.crit{color:#f85149}"
|
||||||
|
".smsg{font-size:11px;font-weight:500}.smsg.ok{color:#c9d1d9}.smsg.crit{color:#f85149}"
|
||||||
|
".cpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||||
|
".clabel{font-size:8px;letter-spacing:0.15em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:8px}"
|
||||||
|
".cbtn{width:100%;padding:7px 0;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
|
||||||
|
".cbtn:hover{background:rgba(255,255,255,0.1)}"
|
||||||
|
".wh-row{margin-top:10px;position:relative;z-index:1}"
|
||||||
|
".wh-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||||
|
".wh-url{font-size:9px;color:rgba(255,255,255,0.35);word-break:break-all;margin-bottom:8px}"
|
||||||
|
".wh-input{width:100%;padding:6px 8px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px}"
|
||||||
|
".cbtn-save{width:100%;padding:7px 0;background:rgba(46,160,67,0.15);border:1px solid rgba(46,160,67,0.3);border-radius:6px;color:#2ea043;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
|
||||||
|
".emg-row{margin-top:10px;position:relative;z-index:1}"
|
||||||
|
".emg-panel{background:rgba(248,81,73,0.06);border:1px solid rgba(248,81,73,0.25);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}"
|
||||||
|
"</style></head><body><div class='dash'><div class='grid-bg'></div>");
|
||||||
|
|
||||||
|
html += F("<div class='header'>"
|
||||||
|
"<div><div class='sys-label'>SYS · PUMP_CTRL</div>"
|
||||||
|
"<div class='title'>Мониторинг<br>Насосов</div>"
|
||||||
|
"<div class='refresh-badge'><div class='pulse-dot'></div>LIVE · AUTO</div></div>"
|
||||||
|
"<div class='time-block'><div class='time-val' id='clk'>--:--:--</div><div class='date-val' id='dt'></div></div>"
|
||||||
|
"</div>");
|
||||||
|
|
||||||
|
html += "<div class='pumps-grid'>";
|
||||||
|
|
||||||
|
// Насос 1
|
||||||
|
html += "<div class='pump-card" + String(p1.stableState ? " active" : "") + "'>";
|
||||||
|
html += F("<div class='pump-type'>Насос</div>"
|
||||||
|
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||||
|
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"</svg>");
|
||||||
|
html += "<div class='pump-name'>Основной 1</div><div class='pump-status'><div class='led " + String(p1.stableState ? "on" : "off") + "'></div><div class='stext " + String(p1.stableState ? "on" : "off") + "'>" + String(p1.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p1.stableState ? "80" : "0") + "%'></div></div></div>";
|
||||||
|
|
||||||
|
// Насос 2
|
||||||
|
html += "<div class='pump-card" + String(p2.stableState ? " active" : "") + "'>";
|
||||||
|
html += F("<div class='pump-type'>Насос</div>"
|
||||||
|
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||||
|
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"</svg>");
|
||||||
|
html += "<div class='pump-name'>Основной 2</div><div class='pump-status'><div class='led " + String(p2.stableState ? "on" : "off") + "'></div><div class='stext " + String(p2.stableState ? "on" : "off") + "'>" + String(p2.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p2.stableState ? "80" : "0") + "%'></div></div></div>";
|
||||||
|
|
||||||
|
// Резерв
|
||||||
|
html += "<div class='pump-card emergency" + String(pE.stableState ? " active" : "") + "'>";
|
||||||
|
html += F("<div class='pump-type emerg'>Резерв</div>"
|
||||||
|
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||||
|
"<path d='M16 3 L28 27 L4 27 Z' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<line x1='16' y1='12' x2='16' y2='20' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<circle cx='16' cy='23' r='1.5' fill='currentColor'/>"
|
||||||
|
"</svg>");
|
||||||
|
html += "<div class='pump-name'>Резерв</div><div class='pump-status'><div class='led " + String(pE.stableState ? "on emerg" : "off") + "'></div><div class='stext " + String(pE.stableState ? "on emerg" : "off") + "'>" + String(pE.stableState ? "Активен" : "Ожидание") + "</div></div><div class='meter'><div class='mfill" + String(pE.stableState ? " emerg" : "") + "' style='width:" + String(pE.stableState ? "55" : "0") + "%'></div></div></div>";
|
||||||
|
|
||||||
|
html += "</div><div class='bottom-row'>";
|
||||||
|
|
||||||
|
if (isAlarm) {
|
||||||
|
html += "<div class='spanel crit'><svg class='sicon' viewBox='0 0 26 26' fill='none'><path d='M13 3 L23 21 L3 21 Z' stroke='#f85149' stroke-width='1.5' fill='rgba(248,81,73,0.1)'/><line x1='13' y1='10' x2='13' y2='16' stroke='#f85149' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode crit'>CRITICAL · 0xFF</div><div class='smsg crit'>Нет подачи!</div></div></div>";
|
||||||
|
} else {
|
||||||
|
html += "<div class='spanel ok'><svg class='sicon' viewBox='0 0 26 26' fill='none'><circle cx='13' cy='13' r='10' stroke='#2ea043' stroke-width='1.5'/><path d='M8 13 L11.5 16.5 L18 10' stroke='#2ea043' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode ok'>SYS_OK · 0x00</div><div class='smsg ok'>Система: норма</div></div></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
html += "<div class='cpanel'><div class='clabel'>Управление</div><form action='/reset_wifi' method='POST' style='margin:0'><button type='submit' class='cbtn'>Сбросить Wi-Fi</button></form></div></div>";
|
||||||
|
html += "<div class='wh-row'><div class='wh-panel'><div class='clabel'>Webhook URL</div><div class='wh-url'>" + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "</div><form action='/set_webhook' method='POST' style='margin:0'><input class='wh-input' type='text' name='url' placeholder='https://...' maxlength='220'><button type='submit' class='cbtn-save' style='margin-top:6px'>Сохранить</button></form></div></div>";
|
||||||
|
|
||||||
|
if (pE.stableState) {
|
||||||
|
html += "<div class='emg-row'><div class='emg-panel'><svg class='sicon' viewBox='0 0 26 26' fill='none'><path d='M13 3 L23 21 L3 21 Z' stroke='#f85149' stroke-width='1.5' fill='rgba(248,81,73,0.1)'/><circle cx='13' cy='19' r='1' fill='#f85149'/></svg><div><div class='scode crit'>WARN · BACKUP</div><div class='smsg crit'>Работает резерв!</div></div></div></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
html += F("<script>"
|
||||||
|
"let lastS = '';"
|
||||||
|
"function check(){"
|
||||||
|
"fetch('/status').then(r=>r.json()).then(data=>{"
|
||||||
|
"let s = JSON.stringify(data);"
|
||||||
|
"if(lastS !== '' && lastS !== s) location.reload();"
|
||||||
|
"lastS = s;"
|
||||||
|
"}).catch(()=>{});"
|
||||||
|
"}"
|
||||||
|
"function tick(){"
|
||||||
|
"var n=new Date();"
|
||||||
|
"var h=('0'+n.getHours()).slice(-2), m=('0'+n.getMinutes()).slice(-2), s=('0'+n.getSeconds()).slice(-2);"
|
||||||
|
"document.getElementById('clk').textContent=h+':'+m+':'+s;"
|
||||||
|
"var mo=['ЯНВ','ФЕВ','МАР','АПР','МАЙ','ИЮН','ИЮЛ','АВГ','СЕН','ОКТ','НОЯ','ДЕК'];"
|
||||||
|
"document.getElementById('dt').textContent=n.getDate()+' '+mo[n.getMonth()]+' '+n.getFullYear();"
|
||||||
|
"}"
|
||||||
|
"tick(); setInterval(tick,1000); setInterval(check,2000);"
|
||||||
|
"</script></div></body></html>");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user