Настройки через веб, состояние реле в UI, распиновка под реле active-LOW
Распиновка. Пина D0, который использовался под выход «Резерв», в доступном наборе нет. Кнопка сброса Wi-Fi удалена как избыточная: при пропавшей сети wm.autoConnect() сам поднимает портал. Освободившийся D3 закрыл дефицит. Выходы переехали на D3 и D4 и стали инверсными. Причина: для реле active-LOW безопасное состояние — пин в HIGH, а D3/D4 подтянуты к HIGH внешними резисторами платы и держат этот уровень всю загрузку. D8, наоборот, подтянут к LOW и щёлкал бы реле при каждом включении питания. В setup() digitalWrite() идёт до pinMode(), иначе защёлка выхода даёт короткий LOW. Инверсия живёт только в setRelay(); логическое состояние дублируется в relayMainOn/relayEmergOn, чтобы панель показывала смысл, а не уровень пина. Настройки. device, выдержка аварии и выдержка резерва вынесены в /set_config и EEPROM. Раскладка EEPROM версионирована: magic 0xA55B, старый 0xA55A распознаётся и переносится, поэтому прошитые приборы не теряют webhook. Значения по умолчанию воспроизводят прежнее поведение (5 с и 0 с). Для резерва добавлена выдержка, которой раньше не было, симметрично основной аварии. Обе выдержки гасят реле и вебхук одновременно. device_name фильтруется до [A-Za-z0-9_-] вместо экранирования: такой набор безопасен и в JSON, и в HTML. Пустое или неверное поле формы означает «не менять», поэтому частичное заполнение не сбрасывает остальное. Состояние реле выведено в /status и в панель. Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра 3.1.2 — предупреждений в скетче нет (попутно убран неиспользуемый isEmergency); валидация настроек прогнана на 22 граничных случаях; раскладка EEPROM проверена на перекрытия. На железе не проверялось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,23 +42,40 @@
|
||||
**Контакт замкнут = насос работает.** Полярность не важна, внешние резисторы
|
||||
и питание на контакты подавать не нужно.
|
||||
|
||||
### 2.3 Выходы аварии
|
||||
### 2.3 Выходы на реле
|
||||
|
||||
| Клемма | Когда `HIGH` (3.3 В) |
|
||||
|---|---|
|
||||
| `D8` | Авария: оба основных насоса стоят |
|
||||
| `D0` | Работает резервный насос |
|
||||
Прибор рассчитан на **релейные модули с активным LOW** — те, у которых реле
|
||||
включается замыканием входа на GND. Это самый распространённый тип модулей для
|
||||
Arduino.
|
||||
|
||||
Выходы логические, 3.3 В, ток до ~12 мА. **Реле, сирену или лампу напрямую
|
||||
подключать нельзя** — только через транзисторный ключ или релейный модуль
|
||||
с оптронной развязкой.
|
||||
| Клемма платы | Куда | Реле включается когда |
|
||||
|---|---|---|
|
||||
| `D3` | вход `IN` модуля «Авария» | оба основных насоса стоят |
|
||||
| `D4` | вход `IN` модуля «Резерв» | работает резервный насос |
|
||||
|
||||
### 2.4 Кнопка сброса Wi-Fi
|
||||
Питание модулей — от `5V` и `GND` платы (или от отдельного источника с общим
|
||||
GND). **Сирену, лампу или пускатель напрямую к `D3`/`D4` подключать нельзя** —
|
||||
только через релейный модуль или транзисторный ключ.
|
||||
|
||||
Обычная кнопка без фиксации между `D3` и `GND`.
|
||||
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
|
||||
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
|
||||
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
|
||||
> HIGH во время загрузки платы, поэтому реле молчат при включении питания.
|
||||
|
||||
> Кнопку нельзя держать нажатой в момент включения питания — плата уйдёт в
|
||||
> служебный режим и не запустится.
|
||||
Оговорки, которые лучше знать заранее:
|
||||
|
||||
* Реле «Авария» **щёлкает при каждой заливке прошивки** — схема автосброса USB
|
||||
дёргает `D3`. Это нормально.
|
||||
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
|
||||
проверки монтажа.
|
||||
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
|
||||
Если это критично — снимайте нагрузку с **нормально замкнутого (NC)** контакта
|
||||
реле, тогда пропажа питания читается системой как авария.
|
||||
|
||||
### 2.4 Свободные клеммы
|
||||
|
||||
`D8`, `D0` и `A0` прошивкой не используются. Учтите, что `A0` — только
|
||||
аналоговый вход и цифровым выходом стать не может.
|
||||
|
||||
### 2.5 Экран
|
||||
|
||||
@@ -121,19 +138,47 @@ STATUS: OK
|
||||
|
||||
* три карточки насосов с индикаторами;
|
||||
* панель состояния системы (`SYS_OK` или `CRITICAL`);
|
||||
* **состояние обоих реле** — «Включено» / «Выключено»;
|
||||
* кнопка **Сбросить Wi-Fi**;
|
||||
* поле **Webhook URL** для смены адреса уведомлений.
|
||||
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
|
||||
|
||||
Страница обновляется автоматически при изменении состояния насосов.
|
||||
Страница обновляется автоматически при изменении состояния насосов или реле.
|
||||
|
||||
### Адрес уведомлений
|
||||
### 5.1 Состояние реле
|
||||
|
||||
**На новом приборе адрес не задан** — панель покажет
|
||||
`не задан - уведомления отключены`, и сообщения на сервер отправляться не будут.
|
||||
Введите адрес в поле под заголовком `Webhook URL` и нажмите **Сохранить**.
|
||||
Два индикатора показывают, что прибор реально подал на выход:
|
||||
|
||||
Адрес сохраняется в память прибора и переживает отключение питания и сброс
|
||||
Wi-Fi. Максимальная длина — 220 символов.
|
||||
| Индикатор | Значение |
|
||||
|---|---|
|
||||
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
|
||||
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
|
||||
|
||||
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не
|
||||
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
||||
|
||||
### 5.2 Настройки
|
||||
|
||||
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно
|
||||
поправить одну выдержку, не трогая остальное. Неверное значение так же
|
||||
игнорируется, поэтому опечатка ничего не испортит.
|
||||
|
||||
| Поле | Что задаёт | Допустимо | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан |
|
||||
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
||||
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
|
||||
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
|
||||
|
||||
Настройки сохраняются в памяти прибора, переживают отключение питания и сброс
|
||||
Wi-Fi.
|
||||
|
||||
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
|
||||
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
||||
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
|
||||
|
||||
**На новом приборе адрес уведомлений не задан** — панель покажет
|
||||
`не задан - уведомления отключены`, сообщения отправляться не будут, всё
|
||||
остальное работает.
|
||||
|
||||
---
|
||||
|
||||
@@ -161,17 +206,21 @@ Wi-Fi. Максимальная длина — 220 символов.
|
||||
|
||||
## 7. Сброс настроек Wi-Fi
|
||||
|
||||
Нужен при смене роутера или пароля сети.
|
||||
Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет.
|
||||
|
||||
**Способ 1 — кнопкой:**
|
||||
нажмите и держите кнопку на `D3`. На экране появится полоса и обратный отсчёт
|
||||
от 3 секунд. Дождитесь конца — прибор перезагрузится и снова поднимет точку
|
||||
доступа `Pump_Control_Set`. Отпустите кнопку раньше — ничего не произойдёт.
|
||||
**Если сеть пропала или сменился пароль** — ничего делать не нужно: при
|
||||
перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа
|
||||
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
|
||||
|
||||
**Способ 2 — через браузер:**
|
||||
на панели мониторинга нажмите **Сбросить Wi-Fi**.
|
||||
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
|
||||
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку
|
||||
доступа.
|
||||
|
||||
Webhook URL при сбросе Wi-Fi **не стирается**.
|
||||
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
|
||||
прибор по питанию. Не найдя сеть, он откроет портал настройки.
|
||||
|
||||
Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при
|
||||
сбросе Wi-Fi **не стираются**.
|
||||
|
||||
---
|
||||
|
||||
@@ -182,10 +231,17 @@ Webhook URL при сбросе Wi-Fi **не стирается**.
|
||||
|
||||
| Событие | Задержка |
|
||||
|---|---|
|
||||
| Фиксация нового состояния датчика | 3 секунды |
|
||||
| Авария основных насосов (после фиксации) | ещё 5 секунд |
|
||||
| **Итого до сигнала аварии** | **~8 секунд** |
|
||||
| Реакция на резервный насос | ~3 секунды |
|
||||
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
|
||||
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с |
|
||||
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с |
|
||||
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
|
||||
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
|
||||
|
||||
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на
|
||||
реле и на уведомление** — отдельно задержать только уведомление нельзя.
|
||||
|
||||
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
|
||||
антидребезга.
|
||||
|
||||
---
|
||||
|
||||
@@ -199,8 +255,13 @@ Webhook URL при сбросе Wi-Fi **не стирается**.
|
||||
| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) |
|
||||
| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора |
|
||||
| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя |
|
||||
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Выходы аварии при этом срабатывают сразу, без ожидания сервера |
|
||||
| Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой |
|
||||
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
|
||||
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
|
||||
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
|
||||
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет |
|
||||
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
|
||||
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
||||
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
|
||||
|
||||
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
||||
(Arduino IDE → Инструменты → Монитор порта).
|
||||
@@ -210,8 +271,10 @@ Webhook URL при сбросе Wi-Fi **не стирается**.
|
||||
## 10. Безопасность
|
||||
|
||||
Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может
|
||||
открыть панель, сбросить Wi-Fi и изменить адрес уведомлений. Устанавливайте
|
||||
прибор в закрытом технологическом сегменте сети, не в гостевом Wi-Fi.
|
||||
открыть панель, сбросить Wi-Fi, изменить адрес уведомлений и **увеличить
|
||||
выдержки аварии**, то есть замедлить реакцию сигнализации. Устанавливайте прибор
|
||||
в закрытом технологическом сегменте сети, не в гостевом Wi-Fi.
|
||||
|
||||
Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты.
|
||||
Он не заменяет штатную автоматику защиты насосной станции.
|
||||
Он не заменяет штатную автоматику защиты насосной станции. Кроме того, схема не
|
||||
отказобезопасна: обесточенный контроллер сигнала аварии не подаёт (см. 2.3).
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) |
|
||||
| `README.md` | Техническая документация (этот файл) |
|
||||
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||||
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
||||
|
||||
---
|
||||
|
||||
@@ -26,32 +27,70 @@ Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или
|
||||
|
||||
### 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` | `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 |
|
||||
| `D3` | `OUT_ALARM_MAIN` | `OUTPUT` | `LOW` = реле «Авария» включено |
|
||||
| `D4` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `LOW` = реле «Резерв» включено |
|
||||
| `D8`, `D0`, `A0` | — | — | не используются |
|
||||
|
||||
Замечания по особенностям ESP8266:
|
||||
### 2.3 Почему выходы именно на D3 и D4
|
||||
|
||||
* `D3` (GPIO0) и `D8` (GPIO15) участвуют в выборе режима загрузки. `D8` должен
|
||||
быть подтянут к GND при старте — не вешайте на него нагрузку, тянущую пин вверх.
|
||||
Кнопку на `D3` при старте держать нажатой нельзя (плата уйдёт в режим прошивки).
|
||||
* `D0` (GPIO16) не имеет внутреннего pull-up (только pull-down) и не поддерживает
|
||||
прерывания — используется только как выход.
|
||||
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
|
||||
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
|
||||
выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания.
|
||||
|
||||
### 2.3 Дисплей
|
||||
| Пин | 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.4 Подключение датчиков
|
||||
### 2.6 Подключение датчиков
|
||||
|
||||
Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ).
|
||||
Замкнут на GND → насос считается работающим. Внешние резисторы не нужны,
|
||||
@@ -100,27 +139,40 @@ Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600, Flash size 4MB.
|
||||
|
||||
```
|
||||
bothStopped -> запуск таймера mainAlarmStartTime
|
||||
выдержка alarmDelay = 5000 мс
|
||||
-> OUT_ALARM_MAIN = HIGH
|
||||
выдержка alarmDelaySec (настраивается, по умолчанию 5 с)
|
||||
-> реле «Авария» включено
|
||||
-> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно)
|
||||
|
||||
любой насос запустился
|
||||
-> OUT_ALARM_MAIN = LOW
|
||||
-> реле «Авария» выключено
|
||||
-> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно)
|
||||
```
|
||||
|
||||
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг) + `5 с`
|
||||
(выдержка) ≈ **8 секунд**.
|
||||
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг, константа
|
||||
`debounceDelay`) + `alarmDelaySec`. При значениях по умолчанию ≈ **8 секунд**.
|
||||
|
||||
### 4.4 Резервный насос
|
||||
|
||||
Реакция без дополнительной выдержки, сразу после стабилизации входа:
|
||||
Логика симметрична основной аварии — та же схема «ожидание, затем реакция»:
|
||||
|
||||
```
|
||||
pE = работает -> OUT_ALARM_EMERGENCY = HIGH, webhook EMERGENCY PUMP / STARTED
|
||||
pE = стоит -> OUT_ALARM_EMERGENCY = LOW, webhook EMERGENCY PUMP / STOPPED
|
||||
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 на каждый переход состояния.
|
||||
|
||||
@@ -139,6 +191,11 @@ pE = стоит -> OUT_ALARM_EMERGENCY = LOW, webhook EMERGENCY PUMP / STOPP
|
||||
| `MAIN PUMPS` | `ALARM` / `OK` |
|
||||
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
|
||||
|
||||
Поле `device` берётся из настройки `device_name` (задаётся через веб-панель,
|
||||
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
|
||||
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
|
||||
запроса ему нечем.
|
||||
|
||||
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` —
|
||||
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
||||
локальную/корпоративную инфраструктуру, но не защищает от MITM.
|
||||
@@ -186,19 +243,41 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о
|
||||
|
||||
## 6. Хранение настроек (EEPROM)
|
||||
|
||||
Эмулируемая EEPROM, 256 байт.
|
||||
Эмулируемая EEPROM, 512 байт. Занято 258.
|
||||
|
||||
| Адрес | Размер | Содержимое |
|
||||
|---|---|---|
|
||||
| `0` | 2 | Magic `0xA55A` — признак инициализации |
|
||||
| `2` | 220 | Webhook URL, `\0`-терминированный |
|
||||
| Адрес | Размер | Содержимое | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| `0` | 2 | Magic `0xA55B` — версия раскладки 2 | — |
|
||||
| `2` | 220 | `webhook_url`, `\0`-терминированный | пусто |
|
||||
| `222` | 31 | `device_name` | `Wemos_D1_Pump` |
|
||||
| `254` | 2 | `alarm_delay_sec` (`uint16`) | `5` |
|
||||
| `256` | 2 | `emerg_delay_sec` (`uint16`) | `0` |
|
||||
|
||||
При первом старте (magic не совпал) записывается значение константы
|
||||
`webhook_url_default` — она намеренно пустая, чтобы секретный токен не попадал
|
||||
в исходник и в git. Пока URL не задан через `/set_webhook`, `sendPostWebhook()`
|
||||
сразу выходит и пишет в лог `[HTTP] Webhook URL не задан`.
|
||||
Значения по умолчанию воспроизводят поведение прошивки до появления настроек.
|
||||
|
||||
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM.
|
||||
### 6.1 Версионирование и миграция
|
||||
|
||||
Magic одновременно служит номером версии раскладки:
|
||||
|
||||
| Прочитанный magic | Действие |
|
||||
|---|---|
|
||||
| `0xA55B` | читаются все поля |
|
||||
| `0xA55A` | **миграция v1 → v2**: `webhook_url` сохраняется, новые поля получают значения по умолчанию, magic перезаписывается |
|
||||
| иное | первый старт, все поля по умолчанию |
|
||||
|
||||
Миграция нужна, чтобы уже прошитые приборы не потеряли настроенный webhook при
|
||||
обновлении прошивки.
|
||||
|
||||
При чтении v2 значения дополнительно проверяются: пустое `device_name` и
|
||||
выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию — иначе мусор
|
||||
во флеше дал бы заведомо неверную выдержку.
|
||||
|
||||
Пустой `webhook_url` — легальное состояние. Пока URL не задан,
|
||||
`sendPostWebhook()` сразу выходит и пишет в лог
|
||||
`[HTTP] Webhook URL не задан — отправка пропущена`.
|
||||
|
||||
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM,
|
||||
поэтому сброс Wi-Fi настройки из этой таблицы не затрагивает.
|
||||
|
||||
---
|
||||
|
||||
@@ -209,16 +288,45 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о
|
||||
| Маршрут | Метод | Описание |
|
||||
|---|---|---|
|
||||
| `/` | `GET` | HTML-панель мониторинга |
|
||||
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0}` |
|
||||
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` |
|
||||
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
||||
| `/set_webhook` | `POST` | Параметр `url` (≤220 симв.), сохранить в EEPROM |
|
||||
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
|
||||
|
||||
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при
|
||||
изменении состояния. Шрифты подгружаются с Google Fonts — при отсутствии
|
||||
интернета у клиента интерфейс отрисуется системным моноширинным шрифтом.
|
||||
### 7.1 `/set_config`
|
||||
|
||||
Аутентификации нет: любой в той же сети может сбросить Wi-Fi и подменить
|
||||
webhook. Контроллер рассчитан на изолированный технологический сегмент.
|
||||
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** —
|
||||
так частично заполненная форма не обнуляет остальные параметры. Запись в EEPROM
|
||||
выполняется только если что-то реально изменилось.
|
||||
|
||||
| Параметр | Валидация | При отказе |
|
||||
|---|---|---|
|
||||
| `url` | 1…220 символов | поле не меняется |
|
||||
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется |
|
||||
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
|
||||
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
|
||||
|
||||
Фильтрация `device` вместо экранирования выбрана осознанно: разрешённый набор
|
||||
символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно
|
||||
нигде.
|
||||
|
||||
### 7.2 Состояние реле
|
||||
|
||||
`rMain` и `rEmg` в `/status` — это **логическое** состояние реле из переменных
|
||||
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано
|
||||
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
|
||||
обратное действительности.
|
||||
|
||||
### 7.3 Автообновление
|
||||
|
||||
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при любом
|
||||
изменении JSON. Поскольку состояние реле теперь входит в ответ, страница
|
||||
обновляется и при срабатывании реле. Шрифты подгружаются с Google Fonts — при
|
||||
отсутствии интернета у клиента интерфейс отрисуется системным моноширинным
|
||||
шрифтом.
|
||||
|
||||
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить webhook
|
||||
и изменить выдержки. Контроллер рассчитан на изолированный технологический
|
||||
сегмент.
|
||||
|
||||
---
|
||||
|
||||
@@ -246,13 +354,27 @@ STATUS: OK
|
||||
| работает резерв | `WARN: BACKUP RUNNING` |
|
||||
| иначе | `STATUS: OK` |
|
||||
|
||||
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны
|
||||
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Кнопка сброса Wi-Fi
|
||||
## 9. Сброс настроек Wi-Fi
|
||||
|
||||
Пин `D3`, удержание `BTN_HOLD_MS = 3000 мс`. Во время удержания на дисплее
|
||||
рисуется прогресс-бар с обратным отсчётом. По достижении порога —
|
||||
`wm.resetSettings()` и `ESP.restart()`. Отпускание до порога отменяет операцию.
|
||||
Физической кнопки сброса нет — она удалена осознанно. При сохранённых учётных
|
||||
данных и пропавшей сети `wm.autoConnect()` не подключается и **сам** поднимает
|
||||
портал `Pump_Control_Set`, поэтому для этого сценария кнопка не нужна.
|
||||
|
||||
Остаётся два способа:
|
||||
|
||||
* кнопка **«Сбросить Wi-Fi»** на веб-панели (`POST /reset_wifi`) — работает, пока
|
||||
прибор в сети и панель доступна;
|
||||
* выключить роутер и перезагрузить прибор — `autoConnect()` не найдёт сеть и
|
||||
откроет портал.
|
||||
|
||||
Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо
|
||||
перевести в другую сеть»: простая перезагрузка портал не откроет, так как
|
||||
подключение проходит успешно.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Настраиваемые параметры, состояние реле, ревизия распиновки
|
||||
|
||||
Дата: 2026-08-07
|
||||
Статус: согласовано, к реализации
|
||||
|
||||
## Задачи
|
||||
|
||||
1. Пересмотреть распиновку под доступный набор пинов (A0, D1–D8) и релейные
|
||||
модули active-LOW.
|
||||
2. Вынести в веб-панель три параметра: имя устройства, выдержку аварии основных
|
||||
насосов, выдержку пуска резервного насоса.
|
||||
3. Показать состояние обоих реле в веб-панели.
|
||||
|
||||
## Исходные условия
|
||||
|
||||
* Доступны только A0 и D1–D8. Пина `D0`, который использует текущий код, нет.
|
||||
* A0 — только аналоговый вход: ни цифровым выходом, ни входом с подтяжкой быть
|
||||
не может, поэтому в бюджет цифровых пинов не входит.
|
||||
* Реле — готовые модули для Arduino с обвязкой, **active-LOW** (вход на GND =
|
||||
реле включено). На момент проектирования ещё не подключены, поэтому логику
|
||||
выходов задаём с нуля.
|
||||
|
||||
## 1. Распиновка
|
||||
|
||||
Кнопка сброса Wi-Fi удаляется. Обоснование: при сохранённых учётных данных и
|
||||
пропавшей сети `wm.autoConnect()` не подключается и сам поднимает портал
|
||||
`Pump_Control_Set` — кнопка для этого не нужна. Для случая «сеть есть, но надо
|
||||
переехать в другую» остаётся кнопка на веб-панели; физически то же достигается
|
||||
выключением роутера и перезагрузкой прибора.
|
||||
|
||||
Освободившийся D3 закрывает дефицит пинов.
|
||||
|
||||
Для реле active-LOW безопасное состояние — пин в **HIGH**, поэтому выходы должны
|
||||
сидеть на пинах, уже подтянутых к HIGH во время загрузки, пока `setup()` ещё не
|
||||
выполнился:
|
||||
|
||||
| Пин | Уровень при загрузке | Реле active-LOW при загрузке |
|
||||
|---|---|---|
|
||||
| D3 | подтянут HIGH (10к) | выключено ✓ |
|
||||
| D4 | подтянут HIGH (10к) | выключено ✓ |
|
||||
| D8 | подтянут **LOW** (10к) | включено ✗ ложная авария при каждом старте |
|
||||
| D1/D2/D5/D6/D7 | не определён до `pinMode` | неопределённо |
|
||||
|
||||
Итоговая распиновка:
|
||||
|
||||
```
|
||||
D1 SDA — OLED (без изменений)
|
||||
D2 SCL — OLED (без изменений)
|
||||
D5 IN_PUMP_1 (INPUT_PULLUP, LOW = работает)
|
||||
D6 IN_PUMP_2 (INPUT_PULLUP, LOW = работает)
|
||||
D7 IN_PUMP_EMERGENCY (INPUT_PULLUP, LOW = работает)
|
||||
D3 OUT_ALARM_MAIN (выход, active-LOW: LOW = реле включено)
|
||||
D4 OUT_ALARM_EMERGENCY (выход, active-LOW: LOW = реле включено)
|
||||
D8, A0, D0 — не используются
|
||||
```
|
||||
|
||||
Особенности, принятые осознанно:
|
||||
|
||||
* D4 — встроенный светодиод платы, тоже активен от LOW. Реле «Резерв» получает
|
||||
бесплатную индикацию на плате.
|
||||
* D3 — пин выбора режима прошивки. Схема автосброса USB дёргает его вниз при
|
||||
заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке. Косметика.
|
||||
* Схема не fail-safe: авария включает реле, поэтому обесточенный контроллер
|
||||
сигнала не даёт. При необходимости решается на монтаже — снятием нагрузки с
|
||||
нормально замкнутого (NC) контакта реле.
|
||||
|
||||
Инверсия уровня локализуется в одной функции, чтобы её нельзя было применить
|
||||
дважды или забыть:
|
||||
|
||||
```cpp
|
||||
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
|
||||
```
|
||||
|
||||
В `setup()` `digitalWrite(pin, HIGH)` вызывается **до** `pinMode(pin, OUTPUT)`:
|
||||
иначе защёлка выхода может кратковременно выдать LOW и щёлкнуть реле.
|
||||
|
||||
## 2. Конфигурация в EEPROM
|
||||
|
||||
Три новых параметра не влезают в оставшиеся 34 байта (текущая раскладка занимает
|
||||
222 из 256), поэтому `EEPROM_SIZE` увеличивается до 512.
|
||||
|
||||
| Адрес | Размер | Поле | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | magic `0xA55B` | — |
|
||||
| 2 | 220 | `webhook_url` | пусто |
|
||||
| 222 | 32 | `device_name` | `Wemos_D1_Pump` |
|
||||
| 254 | 2 | `alarm_delay_sec` (uint16) | 5 |
|
||||
| 256 | 2 | `emerg_delay_sec` (uint16) | 0 |
|
||||
|
||||
Значения по умолчанию совпадают с текущим поведением прошивки: `5` — нынешний
|
||||
`alarmDelay = 5000 мс`, `0` — нынешняя мгновенная реакция на резерв,
|
||||
`Wemos_D1_Pump` — нынешнее захардкоженное значение поля `device` в теле хука.
|
||||
|
||||
Миграция по magic:
|
||||
|
||||
* `0xA55B` — читаются все поля.
|
||||
* `0xA55A` (старая раскладка) — сохраняется `webhook_url`, новые поля получают
|
||||
значения по умолчанию, magic перезаписывается. Прошитые приборы не теряют
|
||||
адрес webhook.
|
||||
* иное — первый старт, все поля по умолчанию.
|
||||
|
||||
## 3. Валидация
|
||||
|
||||
`device_name` попадает в тело JSON, поэтому кавычка или обратный слэш сломали бы
|
||||
запрос. Вместо экранирования — ограничение набора символов: разрешены
|
||||
`A-Z a-z 0-9 _ -`, до 31 символа, остальные символы отбрасываются. Если после
|
||||
фильтрации строка пуста, поле не меняется. Это же снимает вопрос с выводом
|
||||
значения в HTML.
|
||||
|
||||
Задержки задаются целыми секундами, допустимый диапазон 0…3600. Значение вне
|
||||
диапазона или нечисловое — поле не меняется.
|
||||
|
||||
Пустой `webhook_url` — легальное состояние: отправка событий пропускается,
|
||||
автономная работа это штатный режим.
|
||||
|
||||
## 4. Логика задержек
|
||||
|
||||
Одна выдержка на канал, гасит и реле, и вебхук одновременно.
|
||||
|
||||
`alarm_delay_sec` заменяет константу `alarmDelay` в существующей паре
|
||||
`mainAlarmPending` / `mainAlarmStartTime` — поведение основных насосов не
|
||||
меняется, кроме настраиваемости.
|
||||
|
||||
Для резерва добавляется симметричная пара `emergPending` / `emergStartTime`,
|
||||
которой сейчас нет:
|
||||
|
||||
```
|
||||
pE работает:
|
||||
выдержка не запущена -> запустить
|
||||
выдержка истекла -> setRelay(EMERG, true) + вебхук STARTED (однократно)
|
||||
pE стоит:
|
||||
setRelay(EMERG, false) + вебхук STOPPED (однократно), выдержка сброшена
|
||||
```
|
||||
|
||||
При `emerg_delay_sec = 0` условие выполняется на следующей же итерации, то есть
|
||||
нынешнее мгновенное поведение сохраняется.
|
||||
|
||||
Полная задержка до сигнала = 3 с (дребезг, константа `debounceDelay`) плюс
|
||||
настроенная выдержка.
|
||||
|
||||
## 5. Веб-интерфейс
|
||||
|
||||
`/set_webhook` заменяется на `/set_config` — одна форма на четыре поля (URL, имя
|
||||
устройства, две задержки). Ломающее изменение для маршрута; внешних потребителей
|
||||
у него нет.
|
||||
|
||||
`/status` расширяется:
|
||||
|
||||
```json
|
||||
{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}
|
||||
```
|
||||
|
||||
Состояние реле берётся из логических переменных `relayMainOn` / `relayEmergOn`,
|
||||
а не из `digitalRead` — иначе на панели легко получить инвертированную неправду.
|
||||
Побочный эффект: скрипт панели сравнивает JSON целиком, поэтому страница теперь
|
||||
перезагружается и при изменении состояния реле.
|
||||
|
||||
На панель добавляются:
|
||||
|
||||
* ряд из двух индикаторов реле (существующие стили `.spanel`);
|
||||
* блок настроек с текущими значениями полей.
|
||||
|
||||
## 6. Вне объёма
|
||||
|
||||
* OLED не меняется — строки для реле там нет без переработки макета.
|
||||
* Ретраи и очередь вебхуков не добавляются (решение принято ранее).
|
||||
* Пауза мониторинга до 2 минут при старте без сохранённой сети остаётся как есть.
|
||||
* Попутно удаляется неиспользуемая переменная `isEmergency` в `getHTML()` —
|
||||
единственное предупреждение компилятора в скетче.
|
||||
|
||||
## 7. Проверка
|
||||
|
||||
Автоматически:
|
||||
|
||||
* Синтаксическая сборка `xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra`
|
||||
против ядра 3.1.2 — в скетче не должно остаться предупреждений.
|
||||
* Логика фильтрации имени устройства и разбора чисел — таблицей граничных
|
||||
случаев.
|
||||
|
||||
На железе (остаётся за пользователем):
|
||||
|
||||
* при подаче питания оба реле молчат;
|
||||
* миграция EEPROM сохранила ранее заданный webhook;
|
||||
* заданные выдержки соблюдаются, при 0 резерв реагирует сразу;
|
||||
* панель показывает состояние реле, совпадающее с фактическим щелчком.
|
||||
@@ -2,15 +2,29 @@
|
||||
============================================================
|
||||
Контроллер насосов v8 — Wemos D1 Mini
|
||||
============================================================
|
||||
Распиновка:
|
||||
Распиновка (реле — модули active-LOW: LOW на входе = реле включено):
|
||||
D1 — OLED SDA (I2C)
|
||||
D2 — OLED SCL (I2C)
|
||||
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)
|
||||
D3 — OUT_ALARM_MAIN (выход, LOW = реле включено)
|
||||
D4 — OUT_ALARM_EMERGENCY (выход, LOW = реле включено)
|
||||
D8, D0, A0 — не используются
|
||||
|
||||
Почему выходы именно на D3 и D4: для реле active-LOW безопасное состояние —
|
||||
пин в HIGH, а D3 и D4 подтянуты к HIGH внешними резисторами платы и держат
|
||||
этот уровень всю загрузку, пока setup() ещё не выполнился. D8 подтянут к LOW
|
||||
и щёлкал бы реле при каждом включении питания.
|
||||
|
||||
Особенности:
|
||||
- D4 — встроенный светодиод платы (тоже активен от LOW), даёт бесплатную
|
||||
индикацию реле «Резерв».
|
||||
- D3 — пин выбора режима прошивки; схема автосброса USB дёргает его вниз,
|
||||
поэтому реле «Авария» щёлкает при заливке скетча. Косметика.
|
||||
- Схема не fail-safe: обесточенный контроллер сигнала аварии не даёт.
|
||||
При необходимости решается на монтаже — нагрузка снимается с нормально
|
||||
замкнутого (NC) контакта реле.
|
||||
|
||||
Библиотеки (установить через Library Manager):
|
||||
- Adafruit SSD1306
|
||||
@@ -40,31 +54,45 @@ 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.
|
||||
const char* config_ap_name = "Pump_Control_Set";
|
||||
|
||||
// Адрес webhook задаётся через веб-панель (/set_config) и хранится в EEPROM.
|
||||
// В исходнике намеренно пусто: URL содержит секретный токен.
|
||||
const char* webhook_url_default = "";
|
||||
const char* WEBHOOK_URL_DEFAULT = "";
|
||||
const char* DEVICE_NAME_DEFAULT = "Wemos_D1_Pump";
|
||||
const uint16_t ALARM_DELAY_DEFAULT_SEC = 5; // выдержка аварии основных насосов
|
||||
const uint16_t EMERG_DELAY_DEFAULT_SEC = 0; // выдержка пуска резерва (0 = сразу)
|
||||
const uint16_t DELAY_MAX_SEC = 3600;
|
||||
|
||||
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;
|
||||
// Раскладка EEPROM. magic 0xA55B — версия 2; 0xA55A это версия 1 (только URL),
|
||||
// она распознаётся и переносится, чтобы прошитые приборы не теряли webhook.
|
||||
const int EEPROM_SIZE = 512;
|
||||
const int EEPROM_MAGIC_ADDR = 0;
|
||||
const uint16_t EEPROM_MAGIC_V2 = 0xA55B;
|
||||
const uint16_t EEPROM_MAGIC_V1 = 0xA55A;
|
||||
const int EEPROM_URL_ADDR = 2;
|
||||
const int WEBHOOK_MAX_LEN = 220;
|
||||
const int EEPROM_DEVICE_ADDR = 222;
|
||||
const int DEVICE_MAX_LEN = 31; // +1 байт на '\0' = 32 байта поля
|
||||
const int EEPROM_ALARM_ADDR = 254;
|
||||
const int EEPROM_EMERG_ADDR = 256;
|
||||
|
||||
char webhook_url[WEBHOOK_MAX_LEN + 1];
|
||||
char webhook_url[WEBHOOK_MAX_LEN + 1];
|
||||
char device_name[DEVICE_MAX_LEN + 1];
|
||||
uint16_t alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
uint16_t emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
|
||||
// --- ПИНЫ ---
|
||||
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 при нажатии)
|
||||
const int IN_PUMP_1 = D5;
|
||||
const int IN_PUMP_2 = D6;
|
||||
const int IN_PUMP_EMERGENCY = D7;
|
||||
const int OUT_ALARM_MAIN = D3; // реле active-LOW
|
||||
const int OUT_ALARM_EMERGENCY = D4; // реле active-LOW
|
||||
|
||||
// --- КНОПКА СБРОСА WiFi ---
|
||||
const unsigned long BTN_HOLD_MS = 3000; // Удержание 3 сек для сброса
|
||||
unsigned long btnPressStart = 0;
|
||||
bool btnHolding = false;
|
||||
// Логическое состояние реле. Держим отдельно от уровня на пине: выводить в
|
||||
// панель digitalRead() с инверсной логикой — верный способ показать неправду.
|
||||
bool relayMainOn = false;
|
||||
bool relayEmergOn = false;
|
||||
|
||||
ESP8266WebServer server(80);
|
||||
|
||||
@@ -82,7 +110,6 @@ 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;
|
||||
|
||||
// --- БЮДЖЕТ ОТПРАВКИ WEBHOOK ---
|
||||
// Полностью неблокирующий HTTPS на ESP8266 невозможен: BearSSL делает хендшейк
|
||||
@@ -99,6 +126,8 @@ bool emergencyActiveSent = false;
|
||||
bool systemReady = false;
|
||||
unsigned long mainAlarmStartTime = 0;
|
||||
bool mainAlarmPending = false;
|
||||
unsigned long emergStartTime = 0;
|
||||
bool emergPending = false;
|
||||
|
||||
// Для обновления дисплея не чаще раза в секунду
|
||||
unsigned long lastDisplayUpdate = 0;
|
||||
@@ -107,29 +136,77 @@ 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);
|
||||
// Запись строки фиксированной длины с добивкой нулями.
|
||||
static void eepromWriteStr(int addr, int fieldLen, const char* value) {
|
||||
int len = (int)strlen(value);
|
||||
for (int i = 0; i < fieldLen; i++) {
|
||||
EEPROM.write(addr + i, i < len ? value[i] : 0);
|
||||
}
|
||||
EEPROM.commit();
|
||||
Serial.println("[EEPROM] URL сохранён: " + String(url));
|
||||
}
|
||||
|
||||
void eepromReadUrl() {
|
||||
static void eepromReadStr(int addr, int fieldLen, char* dst) {
|
||||
for (int i = 0; i < fieldLen; i++) dst[i] = EEPROM.read(addr + i);
|
||||
dst[fieldLen] = '\0';
|
||||
}
|
||||
|
||||
void eepromWriteConfig() {
|
||||
EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_V2);
|
||||
eepromWriteStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url);
|
||||
eepromWriteStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name);
|
||||
EEPROM.put(EEPROM_ALARM_ADDR, alarmDelaySec);
|
||||
EEPROM.put(EEPROM_EMERG_ADDR, emergDelaySec);
|
||||
EEPROM.commit();
|
||||
Serial.printf("[EEPROM] Конфигурация сохранена: device=%s, авария=%u с, резерв=%u с\n",
|
||||
device_name, alarmDelaySec, emergDelaySec);
|
||||
Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)"));
|
||||
}
|
||||
|
||||
static void configSetDefaults() {
|
||||
strncpy(webhook_url, WEBHOOK_URL_DEFAULT, WEBHOOK_MAX_LEN);
|
||||
webhook_url[WEBHOOK_MAX_LEN] = '\0';
|
||||
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
|
||||
device_name[DEVICE_MAX_LEN] = '\0';
|
||||
alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
}
|
||||
|
||||
void eepromReadConfig() {
|
||||
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);
|
||||
|
||||
if (magic == EEPROM_MAGIC_V2) {
|
||||
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url);
|
||||
eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name);
|
||||
EEPROM.get(EEPROM_ALARM_ADDR, alarmDelaySec);
|
||||
EEPROM.get(EEPROM_EMERG_ADDR, emergDelaySec);
|
||||
|
||||
// Защита от мусора во флеше: подставляем значения по умолчанию, а не
|
||||
// уходим в заведомо неверную выдержку.
|
||||
if (device_name[0] == '\0') {
|
||||
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
|
||||
device_name[DEVICE_MAX_LEN] = '\0';
|
||||
}
|
||||
webhook_url[WEBHOOK_MAX_LEN] = '\0';
|
||||
Serial.println("[EEPROM] URL загружен: " + String(webhook_url));
|
||||
if (alarmDelaySec > DELAY_MAX_SEC) alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
if (emergDelaySec > DELAY_MAX_SEC) emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
|
||||
Serial.printf("[EEPROM] Загружено: device=%s, авария=%u с, резерв=%u с\n",
|
||||
device_name, alarmDelaySec, emergDelaySec);
|
||||
Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)"));
|
||||
|
||||
} else if (magic == EEPROM_MAGIC_V1) {
|
||||
// Миграция v1 -> v2: URL сохраняем, новые поля получают значения по умолчанию.
|
||||
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url);
|
||||
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
|
||||
device_name[DEVICE_MAX_LEN] = '\0';
|
||||
alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
eepromWriteConfig();
|
||||
Serial.println(F("[EEPROM] Миграция v1 -> v2 выполнена, webhook сохранён."));
|
||||
|
||||
} else {
|
||||
strncpy(webhook_url, webhook_url_default, WEBHOOK_MAX_LEN);
|
||||
webhook_url[WEBHOOK_MAX_LEN] = '\0';
|
||||
eepromWriteUrl(webhook_url);
|
||||
Serial.println("[EEPROM] Первый старт. URL по умолчанию сохранён.");
|
||||
configSetDefaults();
|
||||
eepromWriteConfig();
|
||||
Serial.println(F("[EEPROM] Первый старт, записаны значения по умолчанию."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,60 +231,14 @@ void eepromReadUrl() {
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Кнопка сброса WiFi (удержание BTN_HOLD_MS мс)
|
||||
// Реле аварий (модули active-LOW: LOW = включено)
|
||||
// ============================================================
|
||||
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 setRelay(int pin, bool on) {
|
||||
digitalWrite(pin, on ? LOW : HIGH);
|
||||
if (pin == OUT_ALARM_MAIN) relayMainOn = on;
|
||||
else if (pin == OUT_ALARM_EMERGENCY) relayEmergOn = on;
|
||||
}
|
||||
|
||||
void updateDisplay() {
|
||||
@@ -366,7 +397,10 @@ void sendPostWebhook(String eventName, String status) {
|
||||
}
|
||||
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}";
|
||||
// device_name отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
|
||||
// в JSON не требуется — сломать тело запроса ему нечем.
|
||||
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status +
|
||||
"\",\"device\":\"" + String(device_name) + "\"}";
|
||||
int code = http.POST(body);
|
||||
|
||||
if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started);
|
||||
@@ -375,6 +409,40 @@ void sendPostWebhook(String eventName, String status) {
|
||||
http.end();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Валидация настроек из веб-формы
|
||||
// ============================================================
|
||||
|
||||
// Оставляет только [A-Za-z0-9_-] и обрезает до DEVICE_MAX_LEN.
|
||||
// Такой набор символов безопасен и в теле JSON, и в HTML, поэтому
|
||||
// экранирование нигде не нужно. Пустой результат = поле не менять.
|
||||
static String sanitizeDeviceName(const String &raw) {
|
||||
String out;
|
||||
out.reserve(DEVICE_MAX_LEN);
|
||||
for (unsigned int i = 0; i < raw.length() && (int)out.length() < DEVICE_MAX_LEN; i++) {
|
||||
char c = raw[i];
|
||||
bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-';
|
||||
if (ok) out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Разбирает выдержку в секундах. Принимает только строку целиком из цифр
|
||||
// в диапазоне 0..DELAY_MAX_SEC. false = поле не менять.
|
||||
static bool parseDelaySec(const String &raw, uint16_t &out) {
|
||||
String s(raw);
|
||||
s.trim();
|
||||
if (s.length() == 0 || s.length() > 4) return false;
|
||||
for (unsigned int i = 0; i < s.length(); i++) {
|
||||
if (s[i] < '0' || s[i] > '9') return false;
|
||||
}
|
||||
long v = s.toInt();
|
||||
if (v < 0 || v > (long)DELAY_MAX_SEC) return false;
|
||||
out = (uint16_t)v;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Дребезг + обновление стабильного состояния
|
||||
// ============================================================
|
||||
@@ -397,8 +465,7 @@ void updatePumpState(Pump &p) {
|
||||
// HTML интерфейс (без изменений логики, только IP-адрес актуален)
|
||||
// ============================================================
|
||||
String getHTML() {
|
||||
bool isAlarm = (!p1.stableState && !p2.stableState);
|
||||
bool isEmergency = pE.stableState;
|
||||
bool isAlarm = (!p1.stableState && !p2.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>'>";
|
||||
@@ -449,6 +516,17 @@ String getHTML() {
|
||||
".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}"
|
||||
".relay-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px;position:relative;z-index:1}"
|
||||
".rpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||
".rpanel.on{border-color:rgba(46,160,67,0.35);background:rgba(46,160,67,0.06)}"
|
||||
".rpanel.on.emerg{border-color:rgba(218,54,51,0.4);background:rgba(218,54,51,0.06)}"
|
||||
".rstate{display:flex;align-items:center;gap:6px;margin-top:6px}"
|
||||
".cfg-row{margin-top:10px;position:relative;z-index:1}"
|
||||
".cfg-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||
".cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}"
|
||||
".cfg-f{display:flex;flex-direction:column;gap:4px}"
|
||||
".cfg-l{font-size:9px;letter-spacing:0.08em;color:rgba(255,255,255,0.3);text-transform:uppercase}"
|
||||
".cfg-wide{grid-column:1 / -1}"
|
||||
"</style></head><body><div class='dash'><div class='grid-bg'></div>");
|
||||
|
||||
html += F("<div class='header'>"
|
||||
@@ -505,7 +583,39 @@ String getHTML() {
|
||||
}
|
||||
|
||||
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>";
|
||||
// --- Состояние реле ---
|
||||
html += "<div class='relay-row'>";
|
||||
html += "<div class='rpanel" + String(relayMainOn ? " on" : "") + "'>";
|
||||
html += F("<div class='clabel'>Реле «Авария»</div>");
|
||||
html += "<div class='rstate'><div class='led " + String(relayMainOn ? "on" : "off") + "'></div>";
|
||||
html += "<div class='stext " + String(relayMainOn ? "on" : "off") + "'>" +
|
||||
String(relayMainOn ? "Включено" : "Выключено") + "</div></div></div>";
|
||||
|
||||
html += "<div class='rpanel emerg" + String(relayEmergOn ? " on" : "") + "'>";
|
||||
html += F("<div class='clabel'>Реле «Резерв»</div>");
|
||||
html += "<div class='rstate'><div class='led " + String(relayEmergOn ? "on emerg" : "off") + "'></div>";
|
||||
html += "<div class='stext " + String(relayEmergOn ? "on emerg" : "off") + "'>" +
|
||||
String(relayEmergOn ? "Включено" : "Выключено") + "</div></div></div>";
|
||||
html += "</div>";
|
||||
|
||||
// --- Настройки ---
|
||||
html += F("<div class='cfg-row'><div class='cfg-panel'><div class='clabel'>Настройки</div>");
|
||||
html += "<div class='wh-url'>Webhook: " +
|
||||
String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "</div>";
|
||||
html += F("<form action='/set_config' method='POST' style='margin:0'><div class='cfg-grid'>");
|
||||
html += F("<div class='cfg-f cfg-wide'><div class='cfg-l'>Webhook URL</div>"
|
||||
"<input class='wh-input' type='text' name='url' placeholder='https://... (пусто = не менять)' maxlength='220'></div>");
|
||||
html += "<div class='cfg-f cfg-wide'><div class='cfg-l'>Имя устройства (A-Z a-z 0-9 _ -)</div>"
|
||||
"<input class='wh-input' type='text' name='device' value='" + String(device_name) +
|
||||
"' maxlength='31'></div>";
|
||||
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка аварии, с</div>"
|
||||
"<input class='wh-input' type='number' name='alarm_delay' min='0' max='3600' value='" +
|
||||
String(alarmDelaySec) + "'></div>";
|
||||
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка резерва, с</div>"
|
||||
"<input class='wh-input' type='number' name='emerg_delay' min='0' max='3600' value='" +
|
||||
String(emergDelaySec) + "'></div>";
|
||||
html += F("</div><button type='submit' class='cbtn-save' style='margin-top:8px'>Сохранить</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>";
|
||||
@@ -561,16 +671,21 @@ void setup() {
|
||||
}
|
||||
|
||||
EEPROM.begin(EEPROM_SIZE);
|
||||
eepromReadUrl();
|
||||
eepromReadConfig();
|
||||
|
||||
pinMode(p1.pin, INPUT_PULLUP);
|
||||
pinMode(p2.pin, INPUT_PULLUP);
|
||||
pinMode(pE.pin, INPUT_PULLUP);
|
||||
pinMode(BTN_WIFI_RESET, INPUT_PULLUP);
|
||||
|
||||
// Порядок важен: сначала задаём HIGH (реле active-LOW выключено), только
|
||||
// потом переводим пин в OUTPUT. Наоборот защёлка выхода может кратковременно
|
||||
// выдать LOW и щёлкнуть реле.
|
||||
digitalWrite(OUT_ALARM_MAIN, HIGH);
|
||||
digitalWrite(OUT_ALARM_EMERGENCY, HIGH);
|
||||
pinMode(OUT_ALARM_MAIN, OUTPUT);
|
||||
pinMode(OUT_ALARM_EMERGENCY, OUTPUT);
|
||||
digitalWrite(OUT_ALARM_MAIN, LOW);
|
||||
digitalWrite(OUT_ALARM_EMERGENCY, LOW);
|
||||
setRelay(OUT_ALARM_MAIN, false);
|
||||
setRelay(OUT_ALARM_EMERGENCY, false);
|
||||
|
||||
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
|
||||
|
||||
@@ -580,9 +695,11 @@ void setup() {
|
||||
});
|
||||
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 += "\"p1\":" + String(p1.stableState ? "1" : "0") + ",";
|
||||
json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ",";
|
||||
json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ",";
|
||||
json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ",";
|
||||
json += "\"rEmg\":" + String(relayEmergOn ? "1" : "0");
|
||||
json += "}";
|
||||
server.send(200, "application/json", json);
|
||||
});
|
||||
@@ -591,17 +708,45 @@ void setup() {
|
||||
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);
|
||||
// Настройки одной формой. Пустое или неверное поле означает «не менять» —
|
||||
// так частично заполненная форма не сбрасывает остальные параметры.
|
||||
server.on("/set_config", HTTP_POST, []() {
|
||||
bool changed = false;
|
||||
|
||||
if (server.hasArg("url")) {
|
||||
String v = server.arg("url"); v.trim();
|
||||
if (v.length() > 0 && v.length() <= WEBHOOK_MAX_LEN) {
|
||||
v.toCharArray(webhook_url, WEBHOOK_MAX_LEN + 1);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
server.sendHeader("Location", "/"); server.send(303);
|
||||
}
|
||||
if (server.hasArg("device")) {
|
||||
String v = sanitizeDeviceName(server.arg("device"));
|
||||
if (v.length() > 0 && v != String(device_name)) {
|
||||
v.toCharArray(device_name, DEVICE_MAX_LEN + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (server.hasArg("alarm_delay")) {
|
||||
uint16_t v;
|
||||
if (parseDelaySec(server.arg("alarm_delay"), v) && v != alarmDelaySec) {
|
||||
alarmDelaySec = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (server.hasArg("emerg_delay")) {
|
||||
uint16_t v;
|
||||
if (parseDelaySec(server.arg("emerg_delay"), v) && v != emergDelaySec) {
|
||||
emergDelaySec = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) eepromWriteConfig();
|
||||
else Serial.println(F("[CONFIG] Изменений нет, запись в EEPROM пропущена"));
|
||||
|
||||
server.sendHeader("Location", "/");
|
||||
server.send(303);
|
||||
});
|
||||
|
||||
// WiFiManager с заставкой на OLED
|
||||
@@ -655,7 +800,6 @@ void setup() {
|
||||
// ============================================================
|
||||
void loop() {
|
||||
server.handleClient();
|
||||
handleResetButton();
|
||||
|
||||
updatePumpState(p1);
|
||||
updatePumpState(p2);
|
||||
@@ -675,13 +819,15 @@ void loop() {
|
||||
}
|
||||
|
||||
// --- Основная авария ---
|
||||
// Выдержка одна на реле и вебхук: при alarmDelaySec = 0 условие выполняется
|
||||
// на следующей же итерации, то есть реакция мгновенная.
|
||||
bool bothStopped = (!p1.stableState && !p2.stableState);
|
||||
if (bothStopped) {
|
||||
if (!mainAlarmPending) {
|
||||
mainAlarmPending = true;
|
||||
mainAlarmStartTime = millis();
|
||||
} else if (millis() - mainAlarmStartTime >= alarmDelay) {
|
||||
digitalWrite(OUT_ALARM_MAIN, HIGH);
|
||||
} else if (millis() - mainAlarmStartTime >= (unsigned long)alarmDelaySec * 1000UL) {
|
||||
setRelay(OUT_ALARM_MAIN, true);
|
||||
if (!mainAlarmSent) {
|
||||
Serial.println(F("[ALERT] Авария основных насосов"));
|
||||
sendPostWebhook("MAIN PUMPS", "ALARM");
|
||||
@@ -689,7 +835,7 @@ void loop() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
digitalWrite(OUT_ALARM_MAIN, LOW);
|
||||
setRelay(OUT_ALARM_MAIN, false);
|
||||
mainAlarmPending = false;
|
||||
if (mainAlarmSent) {
|
||||
Serial.println(F("[INFO] Восстановление: основные насосы OK"));
|
||||
@@ -699,16 +845,26 @@ void loop() {
|
||||
}
|
||||
|
||||
// --- Резервный насос ---
|
||||
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 (pE.stableState) {
|
||||
if (!emergPending) {
|
||||
emergPending = true;
|
||||
emergStartTime = millis();
|
||||
} else if (millis() - emergStartTime >= (unsigned long)emergDelaySec * 1000UL) {
|
||||
setRelay(OUT_ALARM_EMERGENCY, true);
|
||||
if (!emergencyActiveSent) {
|
||||
Serial.println(F("[WARN] Запущен экстренный насос"));
|
||||
sendPostWebhook("EMERGENCY PUMP", "STARTED");
|
||||
emergencyActiveSent = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRelay(OUT_ALARM_EMERGENCY, false);
|
||||
emergPending = false;
|
||||
if (emergencyActiveSent) {
|
||||
Serial.println(F("[INFO] Резерв остановлен"));
|
||||
sendPostWebhook("EMERGENCY PUMP", "STOPPED");
|
||||
emergencyActiveSent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Обновление дисплея раз в секунду ---
|
||||
|
||||
Reference in New Issue
Block a user