diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index d037af0..ff27499 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -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). diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index 360a7b2..cc9ec82 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -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()` не найдёт сеть и + откроет портал. + +Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо +перевести в другую сеть»: простая перезагрузка портал не откроет, так как +подключение проходит успешно. --- diff --git a/pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md b/pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md new file mode 100644 index 0000000..35efabd --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md @@ -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 резерв реагирует сразу; +* панель показывает состояние реле, совпадающее с фактическим щелчком. diff --git a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino index 3a3502e..99fb05d 100644 --- a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino +++ b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino @@ -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(""); html += ""; @@ -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}" "
"); html += F("
" @@ -505,7 +583,39 @@ String getHTML() { } html += "
Управление
"; - html += "
Webhook URL
" + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + // --- Состояние реле --- + html += "
"; + html += "
"; + html += F("
Реле «Авария»
"); + html += "
"; + html += "
" + + String(relayMainOn ? "Включено" : "Выключено") + "
"; + + html += "
"; + html += F("
Реле «Резерв»
"); + html += "
"; + html += "
" + + String(relayEmergOn ? "Включено" : "Выключено") + "
"; + html += "
"; + + // --- Настройки --- + html += F("
Настройки
"); + html += "
Webhook: " + + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + html += F("
"); + html += F("
Webhook URL
" + "
"); + html += "
Имя устройства (A-Z a-z 0-9 _ -)
" + "
"; + html += "
Выдержка аварии, с
" + "
"; + html += "
Выдержка резерва, с
" + "
"; + html += F("
" + "
"); if (pE.stableState) { html += "
WARN · BACKUP
Работает резерв!
"; @@ -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; + } } // --- Обновление дисплея раз в секунду ---