Compare commits

...
13 Commits
Author SHA1 Message Date
oskarvitaliiandClaude Opus 5 42cbb82492 chore: allowlist .gitignore, рабочий каталог D:\Soft\Arduino как корень репо
Локально все скетчи лежат вперемешку в одной папке: рабочие проекты,
старые версии, эксперименты, архивы и драйверы. Чтобы этот каталог мог
служить рабочим деревом репозитория, .gitignore инвертирован — по
умолчанию игнорируется всё, проекты добавляются точечно строкой
"!/ИмяПапки/".

Прежние правила (артефакты сборки, мусор ОС, secrets.h) сохранены и
перенесены ниже белого списка: более поздний шаблон побеждает, поэтому
они продолжают действовать внутри отслеживаемых папок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:13:58 +07:00
oskarvitaliiandClaude Opus 5 2aa7e20fd4 docs: add pump_controller_8_2_OLED_DONE to the projects index
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:56:50 +07:00
oskarvitalii e91d13b8eb Добавить проект pump_controller_8_2_OLED_DONE
Контроллер насосной станции на Wemos D1 mini: мониторинг двух основных
и резервного насоса, два реле аварий, OLED, веб-панель с настройками,
уведомления на HTTPS-webhook.

История проекта влита целиком, в сообщениях коммитов разобраны причины
принятых решений: выбор пинов D3/D4 под реле active-LOW, ограничение
блокировки loop() при отправке webhook, перенос настроек в LittleFS и
найденная причина несохранения настроек (чип 2 МБ при настройке IDE 4 МБ).
2026-08-07 22:56:09 +07:00
oskarvitaliiandClaude Opus 5 3f21b44a65 Уточнить в коде причину отказа от EEPROM
Комментарий утверждал, что запись в EEPROM на этой плате проваливается,
что читается как «EEPROM сломана». На деле причина была в неверном
Flash Size, и с верной настройкой работали бы оба механизма. Формулировка
исправлена, чтобы следующий читатель не пошёл менять хранилище вместо
проверки размера чипа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 20:05:42 +07:00
oskarvitaliiandClaude Opus 5 703c4d46c6 Зафиксировать проверенную на железе раскладку 2MB (FS:64KB)
Прошивка запущена на целевой плате: LittleFS монтируется и отдаёт
45056 Б, файл настроек читается при старте. В документации указан
фактически использованный вариант Flash Size вместо предполагавшегося
FS:128KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 20:04:42 +07:00
oskarvitaliiandClaude Opus 5 963209dcc9 Найдена причина: чип 2 МБ при настройке IDE 4 МБ
Плата имеет физически 2 МБ флеша, в IDE было выбрано 4MB (FS:2MB).
При этой раскладке сектор EEPROM лежит по смещению 0x3FB000, а начало
LittleFS — по 0x400000, тогда как чип заканчивается на 0x200000. Обе
области вне микросхемы, поэтому spi_flash_erase_sector() проваливался
и EEPROM.commit() возвращал false.

Лечится настройкой Flash Size = 2MB (FS:128KB OTA:~960KB), а не кодом:
любая раскладка 2MB кладёт файловую систему ниже 0x1FB000, а EEPROM в
сектор 511. С верной настройкой работали бы оба механизма хранения.

Ранее записанная в README гипотеза про «щель между FS и областью SDK»
была неверной и заменена подтверждённым разбором. Переход на LittleFS
остаётся оправданным по своим качествам, но проблему решает настройка
IDE — в документации это разделено явно, чтобы не ввести в заблуждение
при похожем симптоме на другой плате.

Документация исправлена: плата 2 МБ, а не 4 МБ; требование совпадения
выбранного размера с физическим; предупреждение о потере учётных данных
Wi-Fi при смене раскладки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:51:38 +07:00
oskarvitaliiandClaude Opus 5 4c0f9672f3 Перенести настройки из EEPROM в LittleFS
EEPROM.commit() на целевой плате возвращает false — падает сам вызов SDK.
Проверкой исходников исключены ошибка в библиотеке, перекрытие адресов и
стирание сектора со стороны WiFiManager. Физический размер флеша тоже ни
при чём: LittleFS на этом же чипе работает, что подтверждено соседним
проектом на такой же плате.

Наиболее вероятный механизм: при раскладке 4MB/FS:2MB сектор EEPROM (1019,
0x3FB000) попадает в щель между концом ФС (0x3FA000) и служебной областью
SDK (0x3FC000) и оказывается защищён от стирания. Доказать не удалось,
поэтому в документации гипотеза помечена как гипотеза. Переход на LittleFS
выбран не как обход симптома, а как переезд на подтверждённо работающий
на этом железе механизм.

Настройки теперь в /settings.json (ArduinoJson v7). Загрузка
отказоустойчива: отсутствующий или битый ключ оставляет значение по
умолчанию, поэтому файл от прошлой версии прошивки не ломается при
добавлении полей. Имена ключей вынесены в константы KEY_*, используемые и
при чтении, и при записи.

Молчаливой потери настроек больше нет: при недоступной ФС взводится
settingsFsReady = false, и веб-панель показывает предупреждение, что
настройки не переживут перезагрузку. В лог при старте выводится геометрия
ФС и предупреждение при выборе FS:none.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра
3.1.2 — предупреждений нет. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:41:05 +07:00
oskarvitaliiandClaude Opus 5 ca02a48684 Диагностика: печатать сектор EEPROM и границы FS против физического флеша
commit() возвращает false, то есть падает сам вызов SDK
(spi_flash_erase_sector либо spi_flash_write). Самая вероятная причина —
сектор EEPROM за пределами физической микросхемы.

При раскладке 4MB/FS:2MB сектор EEPROM это 1019 (смещение 0x3FB000), что
требует чипа не меньше 4 МБ. Теперь лог сам печатает это смещение и
сравнивает с ESP.getFlashChipRealSize(), а также границы файловой системы —
последнее важно, потому что при том же дефекте LittleFS по адресу
0x400000 окажется недоступен ровно так же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:21:25 +07:00
oskarvitaliiandClaude Opus 5 fa54e5790f Диагностика сохранения EEPROM: проверять commit() и перечитывание
Настройки не сохраняются после перезагрузки. Причина пока не установлена,
это шаг сбора улик, а не исправление.

Найден дефект, мешавший диагностике: результат EEPROM.commit()
игнорировался, поэтому лог печатал «Конфигурация сохранена» безусловно —
даже при провалившейся записи. Теперь запись подтверждается повторным
чтением сектора из флеша и сравнением всех полей; при расхождении в лог
идут записанные и прочитанные значения.

Добавлена проверка геометрии флеша при старте: несовпадение размера по
настройке IDE с физическим уводит сектор EEPROM за пределы микросхемы,
и запись молча теряется — симптом ровно тот же.

Проверкой чтения исключены: корректность библиотеки EEPROM, раскладка
адресов (258 из 512, без перекрытий), стирание сектора со стороны
WiFiManager::resetSettings() — он вызывает только WiFi.disconnect().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:29:47 +07:00
oskarvitaliiandClaude Opus 5 cf61617a46 Настройки через веб, состояние реле в 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>
2026-08-07 16:02:21 +07:00
oskarvitaliiandClaude Opus 5 4c760e943f Задокументировать два принятых ограничения
Отсутствие ретраев webhook — сознательное решение: автономная работа
без отправки событий это штатный режим.

Пауза мониторинга до 2 минут при старте без сохранённой сети
(блокирующий портал WiFiManager в setup()) — принята как есть,
с указанием способа лечения, если понадобится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:59:57 +07:00
oskarvitaliiandClaude Opus 5 355a9912ab Ограничить блокировку loop() при отправке webhook бюджетом 3 с
BearSSL делает TLS-хендшейк синхронно, поэтому вместо асинхронности
блокировка ограничивается по времени.

Главный источник залипания — не таймауты HTTPClient, а DNS:
WiFiClientSecureCtx::connect(name, port) резолвит имя через
WiFi.hostByName() без таймаута и подвешивает loop() на ~10 с.
Теперь имя разрешается заранее с лимитом 800 мс, результат попадает
в кэш lwIP, и внутренний резолв возвращается мгновенно. Соединение
по-прежнему идёт по имени, поэтому SNI и заголовок Host сохраняются.

Остаток бюджета уходит в http.setTimeout(): он ограничивает и
connect+TLS (через _client->setTimeout() до connect), и чтение
ответа (собственный цикл HTTPClient по _tcpTimeout).

Худший случай: ~3.5 с вместо прежних 10-15 с. Выходы аварии не
затронуты — digitalWrite() и раньше выполнялся до отправки.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против
ядра 3.1.2 — в скетче замечаний нет; разбор URL прогнан на 15
граничных случаях.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:40:42 +07:00
oskarvitaliiandClaude Opus 5 75430a3732 Initial commit: контроллер насосов v8.2 + документация
Прошивка Wemos D1 Mini для мониторинга трёх насосов: OLED,
веб-панель, webhook-уведомления, сброс Wi-Fi кнопкой.

Адрес webhook задаётся через веб-панель и хранится в EEPROM;
в исходнике не хранится, так как содержит секретный токен.

Добавлены README.md (техдокументация) и MANUAL.md (руководство).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:51:22 +07:00
7 changed files with 2019 additions and 0 deletions
+22
View File
@@ -1,12 +1,34 @@
# Рабочий каталог D:\Soft\Arduino содержит не только проекты этого репозитория,
# но и старые версии скетчей, эксперименты, архивы и драйверы. Поэтому здесь
# белый список: по умолчанию игнорируется всё, а в репозиторий попадают только
# перечисленные ниже проекты.
#
# Чтобы добавить проект: допишите строку "!/ИмяПапки/" в блок ниже.
# Имя папки должно совпадать с именем .ino внутри — требование Arduino IDE.
/*
!/.gitignore
!/README.md
!/WeatherPredictor/
!/pump_controller_8_2_OLED_DONE/
# --- Правила ниже действуют внутри разрешённых проектов ---
# Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому
# артефакты сборки исключаются даже внутри отслеживаемых папок.
# Arduino / build artifacts # Arduino / build artifacts
build/ build/
*.bin *.bin
*.elf *.elf
*.hex *.hex
.vscode/ .vscode/
# OS # OS
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
.DS_Store .DS_Store
# Secrets (Wi-Fi creds are set via WiFiManager, not committed) # Secrets (Wi-Fi creds are set via WiFiManager, not committed)
secrets.h secrets.h
+7
View File
@@ -7,3 +7,10 @@ A collection of Arduino / ESP microcontroller projects.
- **[WeatherPredictor](WeatherPredictor/)** — autonomous barometric weather - **[WeatherPredictor](WeatherPredictor/)** — autonomous barometric weather
station on a Wemos D1 mini (ESP8266): local Zambretti forecast, TFT display station on a Wemos D1 mini (ESP8266): local Zambretti forecast, TFT display
and a self-hosted web interface. See its [README](WeatherPredictor/README.md). and a self-hosted web interface. See its [README](WeatherPredictor/README.md).
- **[pump_controller_8_2_OLED_DONE](pump_controller_8_2_OLED_DONE/)** — pump
station monitor on a Wemos D1 mini (ESP8266): watches two main pumps plus a
backup one, drives two alarm relays, shows status on an OLED and in a web
panel, and posts events to an HTTPS webhook. Settings live in LittleFS.
See its [README](pump_controller_8_2_OLED_DONE/README.md) and
[MANUAL](pump_controller_8_2_OLED_DONE/MANUAL.md).
+9
View File
@@ -0,0 +1,9 @@
build/
.build/
*.bin
*.elf
*.hex
*.map
.vscode/
.DS_Store
Thumbs.db
+286
View File
@@ -0,0 +1,286 @@
# Контроллер насосов v8.2 — руководство пользователя
Документ для монтажника и обслуживающего персонала.
Техническое описание кода — в [README.md](README.md).
---
## 1. Что делает прибор
Контроллер следит за тремя насосами:
* **Насос 1** и **Насос 2** — основные;
* **Резерв** — аварийный (экстренный) насос.
Прибор:
1. показывает состояние на экране;
2. включает выход «Авария», если **оба** основных насоса остановлены;
3. включает выход «Резерв», когда запускается резервный насос;
4. отправляет уведомления на сервер (webhook) через интернет;
5. показывает панель мониторинга в браузере в локальной сети.
---
## 2. Подключение
### 2.1 Питание
5 В через microUSB (блок питания не менее 500 мА).
### 2.2 Датчики насосов
К каждому входу подключается **сухой контакт** — свободный контакт пускателя
насоса или датчика потока.
| Клемма платы | Что подключить |
|---|---|
| `D5` и `GND` | Контакт насоса 1 |
| `D6` и `GND` | Контакт насоса 2 |
| `D7` и `GND` | Контакт резервного насоса |
**Контакт замкнут = насос работает.** Полярность не важна, внешние резисторы
и питание на контакты подавать не нужно.
### 2.3 Выходы на реле
Прибор рассчитан на **релейные модули с активным LOW** — те, у которых реле
включается замыканием входа на GND. Это самый распространённый тип модулей для
Arduino.
| Клемма платы | Куда | Реле включается когда |
|---|---|---|
| `D3` | вход `IN` модуля «Авария» | оба основных насоса стоят |
| `D4` | вход `IN` модуля «Резерв» | работает резервный насос |
Питание модулей — от `5V` и `GND` платы (или от отдельного источника с общим
GND). **Сирену, лампу или пускатель напрямую к `D3`/`D4` подключать нельзя**
только через релейный модуль или транзисторный ключ.
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
> HIGH во время загрузки платы, поэтому реле молчат при включении питания.
Оговорки, которые лучше знать заранее:
* Реле «Авария» **щёлкает при каждой заливке прошивки** — схема автосброса USB
дёргает `D3`. Это нормально.
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
проверки монтажа.
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
Если это критично — снимайте нагрузку с **нормально замкнутого (NC)** контакта
реле, тогда пропажа питания читается системой как авария.
### 2.4 Свободные клеммы
`D8`, `D0` и `A0` прошивкой не используются. Учтите, что `A0` — только
аналоговый вход и цифровым выходом стать не может.
### 2.5 Экран
OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
---
## 3. Первое включение
1. Подайте питание. На экране появится `PUMP CTRL v8 / Starting...`.
2. Прибор не найдёт сохранённой сети и поднимет свою точку доступа:
```
Имя сети: Pump_Control_Set
```
3. Подключитесь к ней с телефона или ноутбука (пароль не требуется).
4. Откройте в браузере `http://192.168.4.1` — откроется страница настройки.
5. Выберите вашу Wi-Fi-сеть, введите пароль, нажмите **Save**.
6. Прибор перезагрузится и покажет на экране полученный IP-адрес.
**Запишите этот адрес** — по нему открывается панель мониторинга.
Если за 2 минуты сеть не настроена, прибор переходит в автономный режим:
экран, выходы аварии и логика работают, webhook и веб-панель — нет.
---
## 4. Экран
```
PUMP CONTROLLER v8
────────────────────────
PUMP 1 : RUNNING
PUMP 2 : STOPPED
BACKUP : STANDBY
────────────────────────
192.168.1.42
STATUS: OK
```
| Надпись | Значение |
|---|---|
| `PUMP 1/2 : RUNNING` | Насос работает |
| `PUMP 1/2 : STOPPED` | Насос остановлен |
| `BACKUP : ACTIVE` | Резервный насос запущен |
| `BACKUP : STANDBY` | Резерв в ожидании |
| `WiFi: NO CONNECTION` | Нет связи с сетью |
| `INIT...` | Идёт запуск (первые ~4 секунды) |
| `STATUS: OK` | Норма |
| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание |
| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** |
---
## 5. Панель в браузере
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
На странице:
* три карточки насосов с индикаторами;
* панель состояния системы (`SYS_OK` или `CRITICAL`);
* **состояние обоих реле** — «Включено» / «Выключено»;
* кнопка **Сбросить Wi-Fi**;
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
Страница обновляется автоматически при изменении состояния насосов или реле.
### 5.1 Состояние реле
Два индикатора показывают, что прибор реально подал на выход:
| Индикатор | Значение |
|---|---|
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке.
### 5.2 Настройки
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно
поправить одну выдержку, не трогая остальное. Неверное значение так же
игнорируется, поэтому опечатка ничего не испортит.
| Поле | Что задаёт | Допустимо | По умолчанию |
|---|---|---|---|
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан |
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
Настройки сохраняются в файл во внутренней памяти прибора, переживают
отключение питания и сброс Wi-Fi.
> Если панель показывает красное предупреждение «Файловая система недоступна»
> или настройки пропадают после перезагрузки — причина в настройке `Flash Size`
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
> 2 МБ приводит ровно к такому симптому.
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
**На новом приборе адрес уведомлений не задан** — панель покажет
`не задан - уведомления отключены`, сообщения отправляться не будут, всё
остальное работает.
---
## 6. Уведомления на сервер
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
| Событие | Что означает |
|---|---|
| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят |
| `MAIN PUMPS` / `OK` | Работа восстановлена |
| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос |
| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился |
Уведомления отправляются один раз на каждое изменение, а не повторяются.
Если в момент события связи не было — уведомление **не будет** отправлено
позже, оно теряется.
На отправку отводится не больше **3 секунд**. Если сервер недоступен или
отвечает медленно, прибор прекращает попытку и продолжает работу: экран и
кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не
дожидаясь сервера.
---
## 7. Сброс настроек Wi-Fi
Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет.
**Если сеть пропала или сменился пароль** — ничего делать не нужно: при
перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку
доступа.
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
прибор по питанию. Не найдя сеть, он откроет портал настройки.
Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при
сбросе Wi-Fi **не стираются**.
---
## 8. Задержки срабатывания
Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и
остановки:
| Событие | Задержка |
|---|---|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с |
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с |
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на
реле и на уведомление** — отдельно задержать только уведомление нельзя.
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
антидребезга.
---
## 9. Диагностика
| Симптом | Причина / что делать |
|---|---|
| Экран не горит | Проверьте питание и провода SDA/SCL. Если адрес дисплея `0x3D`, требуется правка прошивки (константа `OLED_ADDR`) |
| Экран горит, но насосы всегда `STOPPED` | Контакты датчиков не замыкаются на `GND`; проверьте общий провод |
| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя |
| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) |
| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора |
| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя |
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет |
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
(Arduino IDE → Инструменты → Монитор порта).
---
## 10. Безопасность
Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может
открыть панель, сбросить Wi-Fi, изменить адрес уведомлений и **увеличить
выдержки аварии**, то есть замедлить реакцию сигнализации. Устанавливайте прибор
в закрытом технологическом сегменте сети, не в гостевом Wi-Fi.
Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты.
Он не заменяет штатную автоматику защиты насосной станции. Кроме того, схема не
отказобезопасна: обесточенный контроллер сигнала аварии не подаёт (см. 2.3).
+530
View File
@@ -0,0 +1,530 @@
# Pump Controller v8.2 (OLED)
Контроллер мониторинга насосной станции на **Wemos D1 Mini (ESP8266)**.
Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие
контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в
локальной сети и отправляет события на HTTPS-webhook (n8n).
---
## 1. Состав репозитория
| Файл | Назначение |
|---|---|
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) |
| `README.md` | Техническая документация (этот файл) |
| `MANUAL.md` | Руководство пользователя / монтажника |
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
---
## 2. Аппаратная часть
### 2.1 Плата
Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на пин `3V3`.
**Флеш на целевой плате — 2 МБ.** Это важно: клоны Wemos D1 Mini встречаются с
1, 2 и 4 МБ, а размер, выбранный в IDE, обязан совпадать с физическим. Иначе
области EEPROM и файловой системы окажутся за пределами микросхемы и настройки
не сохранятся — см. раздел 6.1, там разобран ровно этот случай.
Проверить фактический размер: `ESP.getFlashChipRealSize()`, прошивка печатает
его в лог при старте строкой `[FLASH] по настройке IDE: ... физически: ...`.
### 2.2 Распиновка
Выходы рассчитаны на релейные модули **active-LOW** (`LOW` на входе = реле
включено).
| Пин | Имя в коде | Режим | Логика |
|---|---|---|---|
| `D1` | I2C SDA | — | OLED |
| `D2` | I2C SCL | — | OLED |
| `D5` | `IN_PUMP_1` | `INPUT_PULLUP` | `LOW` = насос 1 работает |
| `D6` | `IN_PUMP_2` | `INPUT_PULLUP` | `LOW` = насос 2 работает |
| `D7` | `IN_PUMP_EMERGENCY` | `INPUT_PULLUP` | `LOW` = резервный насос работает |
| `D3` | `OUT_ALARM_MAIN` | `OUTPUT` | `LOW` = реле «Авария» включено |
| `D4` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `LOW` = реле «Резерв» включено |
| `D8`, `D0`, `A0` | — | — | не используются |
### 2.3 Почему выходы именно на D3 и D4
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания.
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|---|---|---|---|
| `D3` | 0 | подтянут `HIGH` (10к) | выключено ✓ |
| `D4` | 2 | подтянут `HIGH` (10к) | выключено ✓ |
| `D8` | 15 | подтянут **`LOW`** (10к) | **включено** ✗ |
| `D1`,`D2`,`D5`,`D6`,`D7` | 5,4,14,12,13 | не определён до `pinMode` | неопределённо |
Для реле active-**HIGH** вывод был бы обратным: `D8` — лучший пин, `D3`/`D4`
худшие. Логика выходов и выбор пинов связаны жёстко; менять одно без другого
нельзя.
В `setup()` порядок операций тоже важен: `digitalWrite(pin, HIGH)` вызывается
**до** `pinMode(pin, OUTPUT)`, иначе защёлка выхода может кратковременно выдать
`LOW`.
Инверсия уровня локализована в одной функции — единственном месте, где она
существует:
```cpp
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
```
### 2.4 Прочие особенности ESP8266
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
«Резерв» получает бесплатную индикацию на плате.
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
Косметика, но пугает.
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
цифровых пинов не входит.
* Схема **не fail-safe**: авария включает реле, поэтому обесточенный контроллер
сигнала не даёт. Если нужна отказобезопасность — снимайте нагрузку с
нормально замкнутого (NC) контакта реле, тогда пропажа питания читается как
авария.
### 2.5 Дисплей
SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (константа `OLED_ADDR`).
Если экран не найден — в лог уходит сообщение, прошивка продолжает работать
без дисплея (`oledOK = false`).
### 2.6 Подключение датчиков
Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ).
Замкнут на GND → насос считается работающим. Внешние резисторы не нужны,
используется внутренняя подтяжка.
---
## 3. Зависимости
Устанавливаются через Arduino Library Manager:
* Adafruit SSD1306
* Adafruit GFX Library
* WiFiManager (tzapu)
* ArduinoJson (v7)
Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`,
`WiFiClientSecure`, `LittleFS`, `Wire`.
Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600.
**Flash Size: `2MB (FS:64KB OTA:~992KB)`** — под физические 2 МБ целевой платы.
Именно этот вариант проверен на железе: LittleFS отдаёт 45 056 байт полезного
объёма, чего для `settings.json` (несколько сотен байт) с запасом. Подойдёт и
любой другой `2MB (FS:...)`, если понадобится больше места под файлы.
Два требования к этой настройке, оба обязательные:
* **Размер должен совпадать с физическим чипом.** Выбранные 4 МБ на плате с 2 МБ
уводят и EEPROM, и файловую систему за границу микросхемы — настройки молча
не сохраняются (раздел 6.1).
* **Файловая система должна быть ненулевой.** При `FS:none` сохранять настройки
некуда; прошивка скажет об этом в лог при старте и покажет предупреждение в
веб-панели.
Раскладка для 2 МБ (любой из вариантов `2MB (FS:...)`) кладёт файловую систему
ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа.
После смены `Flash Size` заливайте прошивку заново: меняется заголовок
загрузчика и границы областей. Учтите, что **сохранённые учётные данные Wi-Fi
при этом теряются** и прибор поднимет портал `Pump_Control_Set`.
---
## 4. Логика работы
### 4.1 Антидребезг
`updatePumpState()` для каждого входа:
```
если чтение изменилось -> сбросить таймер lastDebounceTime
если чтение стабильно > 3000 мс -> зафиксировать stableState
```
`debounceDelay = 3000 мс` — длинный намеренно: фильтрует пусковые дребезги
пускателя и кратковременные просадки.
### 4.2 Готовность системы
`systemReady` становится `true` через `debounceDelay + 500 мс` после старта.
До этого момента аварии не формируются и webhook не отправляется — иначе
при включении питания система рапортовала бы ложную аварию.
### 4.3 Основная авария
Условие: **оба** основных насоса стоят (`!p1 && !p2`).
```
bothStopped -> запуск таймера mainAlarmStartTime
выдержка alarmDelaySec (настраивается, по умолчанию 5 с)
-> реле «Авария» включено
-> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно)
любой насос запустился
-> реле «Авария» выключено
-> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно)
```
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг, константа
`debounceDelay`) + `alarmDelaySec`. При значениях по умолчанию ≈ **8 секунд**.
### 4.4 Резервный насос
Логика симметрична основной аварии — та же схема «ожидание, затем реакция»:
```
pE = работает -> запуск таймера emergStartTime
выдержка emergDelaySec (настраивается, по умолчанию 0 с)
-> реле «Резерв» включено
-> webhook EMERGENCY PUMP / STARTED (однократно)
pE = стоит -> реле «Резерв» выключено
-> webhook EMERGENCY PUMP / STOPPED (однократно)
```
При `emergDelaySec = 0` условие `millis() - emergStartTime >= 0` выполняется на
следующей же итерации `loop()`, то есть реакция мгновенная.
### 4.5 Однократность и общая выдержка
Обе выдержки гасят **и реле, и webhook одновременно** — это одна настройка на
канал, а не отдельные таймеры для реле и уведомления.
Флаги `mainAlarmSent` / `emergencyActiveSent` гарантируют отправку ровно
одного webhook на каждый переход состояния.
---
## 5. Webhook
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
```json
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
```
| `event` | `status` |
|---|---|
| `MAIN PUMPS` | `ALARM` / `OK` |
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
запроса ему нечем.
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()`
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
локальную/корпоративную инфраструктуру, но не защищает от MITM.
### 5.1 Бюджет отправки
Отправка остаётся синхронной — BearSSL выполняет TLS-хендшейк блокирующе, и
полностью асинхронного HTTPS на ESP8266 без хрупких сторонних библиотек нет.
Вместо этого блокировка **ограничена по времени** бюджетом
`WEBHOOK_BUDGET_MS = 3000 мс`, который расходуется по этапам:
1. **Разбор URL** (`parseWebhookHost`) — извлекает имя хоста, мгновенно.
2. **DNS**`WiFi.hostByName(host, ip, WEBHOOK_DNS_BUDGET_MS)`, лимит 800 мс.
3. **TCP + TLS + чтение ответа**`http.setTimeout(остаток бюджета)`.
Шаг 2 существует именно ради потолка: `WiFiClientSecureCtx::connect(name, port)`
внутри вызывает `WiFi.hostByName()` **без таймаута** и на мёртвом DNS подвешивает
`loop()` примерно на 10 секунд. Предварительный резолв с лимитом кладёт адрес в
кэш lwIP, после чего внутренний резолв возвращается мгновенно. Соединение
по-прежнему устанавливается по имени, поэтому SNI и заголовок `Host` не ломаются
(важно, если webhook живёт за реверс-прокси).
Шаг 3 опирается на то, что `HTTPClient::connect()` вызывает
`_client->setTimeout(_tcpTimeout)` **до** `_client->connect()`, а хендшейк
BearSSL ограничен тем же `_timeout`. Чтение ответа ограничено отдельно —
собственным циклом `HTTPClient` по `_tcpTimeout`.
Проверять бюджет между фазами внутри `HTTPClient` нельзя, поэтому потолок не
строго 3000 мс. Но провал любой фазы обрывает цепочку, так что лимиты не
складываются. Реальные худшие случаи:
| Сценарий | Задержка |
|---|---|
| Успешная отправка | 300–1500 мс |
| DNS не отвечает | ~800 мс |
| IP не отвечает (чёрная дыра) | ~800 мс + остаток бюджета |
| TLS не поднимается | то же |
| Хендшейк прошёл, ответа нет | ~1500 мс + остаток бюджета ≈ 3.5 с |
До введения бюджета те же сценарии давали 10–15 секунд.
Фактическое время каждой отправки пишется в лог: `[HTTP] Код: 200 (412 мс)`.
---
## 6. Хранение настроек (LittleFS)
Настройки лежат в файловой системе LittleFS, в файле `/settings.json`:
```json
{"webhook":"https://...","device":"Wemos_D1_Pump","alarm_delay":5,"emerg_delay":0}
```
| Поле структуры `AppSettings` | Ключ JSON | По умолчанию |
|---|---|---|
| `webhookUrl` (`char[221]`) | `webhook` | пусто |
| `deviceName` (`char[32]`) | `device` | `Wemos_D1_Pump` |
| `alarmDelaySec` (`uint16`) | `alarm_delay` | `5` |
| `emergDelaySec` (`uint16`) | `emerg_delay` | `0` |
Имена ключей вынесены в константы `KEY_*` и используются и при чтении, и при
записи — рассинхронизация имён между load и save самый частый источник багов в
таком коде, а через константу компилятор их разойтись не даст.
Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без
перезагрузки, и глобальные `String` фрагментировали бы кучу.
### 6.1 История: почему настройки не сохранялись
Реальный случай, разобранный на этом проекте. Симптом: настройки и webhook
пропадают после перезагрузки.
**Корневая причина — несовпадение размера флеша.** В IDE было выбрано
`4MB (FS:2MB OTA:~1019KB)`, тогда как физически на плате **2 МБ**:
| Область | Смещение при настройке 4MB | Внутри чипа 2 МБ (`0x200000`)? |
|---|---|---|
| Сектор EEPROM | `0x3FB000` = 4 173 824 | нет, вдвое дальше конца |
| Начало LittleFS | `0x400000` = 2 097 152 | нет, ровно за границей |
`EEPROM.commit()` возвращал `false`, потому что `spi_flash_erase_sector()`
обращался к несуществующему сектору.
**Лечится настройкой IDE, а не кодом:** любой вариант `2MB (FS:...)` кладёт
файловую систему ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа.
С верной настройкой заработали бы оба механизма хранения.
Что было проверено по пути и оказалось ни при чём: библиотека EEPROM, перекрытие
адресов полей, стирание сектора со стороны `WiFiManager::resetSettings()`
(вызывает только `WiFi.disconnect()`).
**Почему всё-таки перешли на LittleFS**, раз причина была не в EEPROM:
* добавление новой настройки — одна строка, без арифметики адресов и без
версионирования раскладки с миграцией;
* отсутствующий ключ в старом файле не ломает чтение;
* содержимое читается глазами;
* тот же механизм используется в соседних проектах — единообразие.
То есть переход остаётся оправданным по своим качествам, но **проблему решила
настройка `Flash Size`**, а не смена хранилища. Не перепутайте это, если
столкнётесь с похожим симптомом на другой плате.
**Диагностика при старте.** Прошивка печатает геометрию и сама указывает на
несоответствие:
```
[FLASH] по настройке IDE: 4194304 Б, физически: 2097152 Б -> !!! НЕ СОВПАДАЕТ !!!
[FLASH] файловая система: смещение 0x200000, размер 2093056 Б <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!
```
### 6.2 Отказоустойчивость
Загрузка не падает на неполном или повреждённом файле. Порядок в
`settingsBegin()`:
1. `settingsDefaults()` — заполнить структуру значениями по умолчанию;
2. `settingsLoad()` — перезаписать полями из JSON; отсутствующий или битый ключ
**оставляет значение по умолчанию**;
3. если файла нет или JSON не разобрался — `settingsSave()` создаёт его заново.
Благодаря пункту 2 файл, записанный прошлой версией прошивки, не ломается при
добавлении новых полей — старый `/settings.json` просто не содержит нового ключа,
и подставляется значение по умолчанию.
`settingsClamp()` дополнительно приводит значения к допустимым: пустое
`deviceName` и выдержка больше `DELAY_MAX_SEC` заменяются значениями по
умолчанию. Вызывается и после чтения, и перед записью.
Если ФС не смонтировалась, выполняется `LittleFS.format()` и повторная попытка.
При окончательной неудаче взводится `settingsFsReady = false`: прибор продолжает
работать на значениях по умолчанию, но **веб-панель показывает предупреждение**,
что настройки не переживут перезагрузку. Молчаливой потери настроек больше нет.
### 6.3 Ресурс флеша
Запись происходит только по факту изменения — из обработчика `/set_config`, и
только если хотя бы одно поле реально изменилось. Периодической записи нет и
добавлять её не следует.
### 6.4 Как добавить новую настройку
Синхронно правятся четыре места:
1. константа `*_DEFAULT` рядом с остальными значениями по умолчанию;
2. поле в `struct AppSettings` и строка в `settingsDefaults()`;
3. константа `KEY_*`, строка в `settingsLoad()` и строка в `settingsSave()`;
4. поле формы в `getHTML()` и его разбор в обработчике `/set_config`.
Старый `/settings.json` без нового ключа не сломается — подставится значение по
умолчанию.
### 6.5 Что не хранится здесь
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, поэтому сброс
Wi-Fi настройки из `/settings.json` не затрагивает, и наоборот.
Пустой `webhookUrl` — легальное состояние. Пока URL не задан,
`sendPostWebhook()` сразу выходит и пишет в лог
`[HTTP] Webhook URL не задан — отправка пропущена`.
---
## 7. Веб-интерфейс
Сервер на порту `80`.
| Маршрут | Метод | Описание |
|---|---|---|
| `/` | `GET` | HTML-панель мониторинга |
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` |
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
### 7.1 `/set_config`
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»**
так частично заполненная форма не обнуляет остальные параметры. Запись в файл
выполняется только если что-то реально изменилось.
| Параметр | Валидация | При отказе |
|---|---|---|
| `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
и изменить выдержки. Контроллер рассчитан на изолированный технологический
сегмент.
---
## 8. OLED
Обновление раз в секунду (`DISPLAY_INTERVAL`).
```
PUMP CONTROLLER v8
────────────────────────
PUMP 1 : RUNNING
PUMP 2 : STOPPED
BACKUP : STANDBY
────────────────────────
192.168.1.42
STATUS: OK
```
Строка статуса:
| Условие | Текст |
|---|---|
| `!systemReady` | `INIT...` |
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) |
| работает резерв | `WARN: BACKUP RUNNING` |
| иначе | `STATUS: OK` |
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`.
---
## 9. Сброс настроек Wi-Fi
Физической кнопки сброса нет — она удалена осознанно. При сохранённых учётных
данных и пропавшей сети `wm.autoConnect()` не подключается и **сам** поднимает
портал `Pump_Control_Set`, поэтому для этого сценария кнопка не нужна.
Остаётся два способа:
* кнопка **«Сбросить Wi-Fi»** на веб-панели (`POST /reset_wifi`) — работает, пока
прибор в сети и панель доступна;
* выключить роутер и перезагрузить прибор — `autoConnect()` не найдёт сеть и
откроет портал.
Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо
перевести в другую сеть»: простая перезагрузка портал не откроет, так как
подключение проходит успешно.
---
## 10. Известные ограничения
* `setInsecure()` — TLS без проверки сертификата.
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета
(см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется
**до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только
обновление OLED, опрос кнопки и веб-панель.
* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются
и не повторяются — webhook теряется, флаги `mainAlarmSent` /
`emergencyActiveSent` выставляются независимо от результата POST.
Ретраи и очередь сознательно не реализованы: контроллер рассчитан на работу
и без отправки событий, автономность — штатный режим, а не отказ.
* **Пауза мониторинга при старте без сохранённой сети.** `wm.autoConnect()`
поднимает портал настройки и блокирует `setup()` в цикле ожидания до
истечения `setConfigPortalTimeout(120)`. Пока `setup()` не завершён, `loop()`
не выполняется: дребезг не считается, аварии не формируются, выходы не
переключаются (они остаются в безопасном `LOW`, выставленном до Wi-Fi).
В установке, где сеть не настраивается никогда, эти 2 минуты повторяются
при каждом включении питания. Принято как есть; при необходимости лечится
`wm.setConfigPortalBlocking(false)` + `wm.process()` в `loop()`с оговоркой,
что в неблокирующем режиме `setConfigPortalTimeout` не применяется и гасить
портал придётся своим таймером.
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
не считается и никак не сигнализируется.
* Webhook URL хранится в `/settings.json` в открытом виде и отдаётся
веб-панелью всем, кто может её открыть. Если URL содержит секретный токен —
доступ к панели равносилен доступу к токену.
---
## 11. Сборка
```bash
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
```
```bash
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
```
Монитор порта: 115200 бод.
@@ -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 резерв реагирует сразу;
* панель показывает состояние реле, совпадающее с фактическим щелчком.
@@ -0,0 +1,980 @@
/*
============================================================
Контроллер насосов 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 = работает)
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) контакта реле.
Настройки хранятся в LittleFS, файл /settings.json.
Библиотеки (установить через Library Manager):
- Adafruit SSD1306
- Adafruit GFX Library
- WiFiManager (tzapu)
- ArduinoJson (v7)
- ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient / LittleFS
(часть ESP8266 core)
ВАЖНО про Flash Size в IDE. Целевая плата имеет 2 МБ флеша, ставить нужно
«2MB (FS:64KB OTA:~992KB)». Выбранный размер обязан совпадать с физическим:
при настройке 4MB на чипе 2MB области EEPROM и файловой системы уходят за
границу микросхемы, и настройки молча не сохраняются. Прошивка печатает
фактический размер в лог при старте строкой [FLASH].
============================================================
*/
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecure.h>
#include <WiFiManager.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <flash_hal.h> // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// --- OLED ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1 // нет отдельного RESET
#define OLED_ADDR 0x3C // обычный адрес SSD1306; попробуй 0x3D если дисплей не найден
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
bool oledOK = false;
// --- КОНФИГУРАЦИЯ ---
const char* config_ap_name = "Pump_Control_Set";
// --- ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ ---
// Webhook намеренно пуст: URL содержит секретный токен, ему не место в исходнике.
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 WEBHOOK_MAX_LEN = 220;
const int DEVICE_MAX_LEN = 31;
// --- ХРАНИЛИЩЕ НАСТРОЕК ---
// LittleFS, а не EEPROM. Важно не перепутать причины: EEPROM переставала
// сохранять из-за неверного Flash Size в IDE (см. шапку файла), а не потому,
// что механизм плох — с верной настройкой работали бы оба. LittleFS выбран
// за то, что новое поле добавляется одной строкой, без арифметики адресов и
// версионирования раскладки, а старый файл не ломается при этом.
// Разбор случая — в README, раздел 6.1.
const char* SETTINGS_PATH = "/settings.json";
// Имена ключей вынесены в константы намеренно: рассинхронизация имён между
// чтением и записью — самый частый источник багов в таком коде, а так
// компилятор не даст им разойтись.
const char* KEY_WEBHOOK = "webhook";
const char* KEY_DEVICE = "device";
const char* KEY_ALARM_DELAY = "alarm_delay";
const char* KEY_EMERG_DELAY = "emerg_delay";
// Строки хранятся как char[], а не String: прибор работает месяцами без
// перезагрузки, и глобальные String фрагментируют кучу.
struct AppSettings {
char webhookUrl[WEBHOOK_MAX_LEN + 1];
char deviceName[DEVICE_MAX_LEN + 1];
uint16_t alarmDelaySec;
uint16_t emergDelaySec;
};
AppSettings settings;
bool settingsFsReady = false; // false = ФС недоступна, настройки не переживут перезагрузку
// --- ПИНЫ ---
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
// Логическое состояние реле. Держим отдельно от уровня на пине: выводить в
// панель digitalRead() с инверсной логикой — верный способ показать неправду.
bool relayMainOn = false;
bool relayEmergOn = false;
ESP8266WebServer server(80);
// --- СТРУКТУРА НАСОСА ---
typedef struct {
int pin;
String name;
bool stableState;
bool lastReading;
unsigned long lastDebounceTime;
} Pump;
Pump p1 = {IN_PUMP_1, "PUMP_1", false, true, 0};
Pump p2 = {IN_PUMP_2, "PUMP_2", false, true, 0};
Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0};
const unsigned long debounceDelay = 3000;
// --- БЮДЖЕТ ОТПРАВКИ WEBHOOK ---
// Полностью неблокирующий HTTPS на ESP8266 невозможен: BearSSL делает хендшейк
// синхронно. Поэтому блокировка не убирается, а ограничивается по времени.
// HTTPClient не позволяет проверять бюджет между своими фазами (TCP connect /
// TLS / чтение ответа), но неудачная фаза обрывает цепочку, поэтому лимиты
// не складываются. Худший реальный случай — успешный хендшейк плюс зависшее
// чтение ответа, около 3.5 с вместо прежних 10-15 с.
const unsigned long WEBHOOK_BUDGET_MS = 3000; // общий бюджет на отправку
const unsigned long WEBHOOK_DNS_BUDGET_MS = 800; // из него — на резолв имени
bool mainAlarmSent = false;
bool emergencyActiveSent = false;
bool systemReady = false;
unsigned long mainAlarmStartTime = 0;
bool mainAlarmPending = false;
unsigned long emergStartTime = 0;
bool emergPending = false;
// Для обновления дисплея не чаще раза в секунду
unsigned long lastDisplayUpdate = 0;
const unsigned long DISPLAY_INTERVAL = 1000;
// ============================================================
// НАСТРОЙКИ (LittleFS + JSON)
// ============================================================
void settingsDefaults() {
strlcpy(settings.webhookUrl, WEBHOOK_URL_DEFAULT, sizeof(settings.webhookUrl));
strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
settings.alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
settings.emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
}
// Приводит значения к допустимым. Нужно и после чтения файла, и после
// миграции: мусор в файле не должен превращаться в неверную выдержку.
static void settingsClamp() {
settings.webhookUrl[WEBHOOK_MAX_LEN] = '\0';
settings.deviceName[DEVICE_MAX_LEN] = '\0';
if (settings.deviceName[0] == '\0') {
strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
}
if (settings.alarmDelaySec > DELAY_MAX_SEC) settings.alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
if (settings.emergDelaySec > DELAY_MAX_SEC) settings.emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
}
// Загрузка отказоустойчива: отсутствующий или битый ключ оставляет значение,
// уже проставленное settingsDefaults(). Благодаря этому файл, записанный
// прошлой версией прошивки, не ломается при добавлении новых полей.
static bool settingsLoad() {
File f = LittleFS.open(SETTINGS_PATH, "r");
if (!f) {
Serial.println(F("[SET] Файл настроек не найден"));
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.printf("[SET] ОШИБКА разбора JSON: %s\n", err.c_str());
return false;
}
const char* v;
v = doc[KEY_WEBHOOK];
if (v) strlcpy(settings.webhookUrl, v, sizeof(settings.webhookUrl));
v = doc[KEY_DEVICE];
if (v) strlcpy(settings.deviceName, v, sizeof(settings.deviceName));
settings.alarmDelaySec = doc[KEY_ALARM_DELAY] | settings.alarmDelaySec;
settings.emergDelaySec = doc[KEY_EMERG_DELAY] | settings.emergDelaySec;
settingsClamp();
Serial.printf("[SET] Загружено: device=%s, авария=%u с, резерв=%u с\n",
settings.deviceName, settings.alarmDelaySec, settings.emergDelaySec);
Serial.println("[SET] URL: " + String(settings.webhookUrl[0] ? settings.webhookUrl : "(не задан)"));
return true;
}
// Перезаписывает файл целиком. true только если запись реально удалась.
bool settingsSave() {
if (!settingsFsReady) {
Serial.println(F("[SET] ОШИБКА: файловая система недоступна, сохранять некуда"));
return false;
}
settingsClamp();
JsonDocument doc;
doc[KEY_WEBHOOK] = settings.webhookUrl;
doc[KEY_DEVICE] = settings.deviceName;
doc[KEY_ALARM_DELAY] = settings.alarmDelaySec;
doc[KEY_EMERG_DELAY] = settings.emergDelaySec;
File f = LittleFS.open(SETTINGS_PATH, "w");
if (!f) {
Serial.println(F("[SET] ОШИБКА: не удалось открыть файл на запись"));
return false;
}
size_t written = serializeJson(doc, f);
f.close();
if (written == 0) {
Serial.println(F("[SET] ОШИБКА: в файл записано 0 байт"));
return false;
}
Serial.printf("[SET] Сохранено (%u Б): device=%s, авария=%u с, резерв=%u с\n",
(unsigned)written, settings.deviceName,
settings.alarmDelaySec, settings.emergDelaySec);
Serial.println("[SET] URL: " + String(settings.webhookUrl[0] ? settings.webhookUrl : "(не задан)"));
return true;
}
// Монтирует ФС и заполняет settings. Вызывать первым из всего, что работает
// с LittleFS: повторный LittleFS.begin() в других местах не нужен.
void settingsBegin() {
settingsDefaults();
if (!LittleFS.begin()) {
Serial.println(F("[FS] Не смонтирована, форматирую..."));
if (!LittleFS.format() || !LittleFS.begin()) {
Serial.println(F("[FS] ОШИБКА: файловая система недоступна."));
Serial.println(F("[FS] Прибор работает, но настройки не переживут перезагрузку."));
settingsFsReady = false;
return;
}
Serial.println(F("[FS] Отформатирована успешно"));
}
settingsFsReady = true;
FSInfo info;
if (LittleFS.info(info)) {
Serial.printf("[FS] Смонтирована: всего %u Б, занято %u Б\n",
(unsigned)info.totalBytes, (unsigned)info.usedBytes);
}
if (!settingsLoad()) {
Serial.println(F("[SET] Создаю файл со значениями по умолчанию"));
settingsSave();
}
}
// ============================================================
// OLED — отображение состояния
// ============================================================
/*
Макет экрана 128x64 (8 строк по 8px при шрифте 1):
┌──────────────────────────┐
│ PUMP CONTROLLER │ строка 0 — заголовок
│ ────────────────────── │ строка 1 — разделитель
│ НАСОС 1: РАБОТАЕТ │ строка 2
│ НАСОС 2: СТОИТ │ строка 3
│ РЕЗЕРВ : СТОИТ │ строка 4
│ ────────────────────── │ строка 5 — разделитель
│ WiFi: 192.168.1.42 │ строка 6
│ СТАТУС: НОРМА │ строка 7
└──────────────────────────┘
При аварии строка 7 мигает "!! АВАРИЯ !!"
*/
// ============================================================
// Реле аварий (модули active-LOW: LOW = включено)
// ============================================================
// Единственное место, где живёт инверсия уровня. Логическое состояние
// дублируется в переменную, чтобы веб-панель показывала смысл, а не уровень.
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() {
if (!oledOK) return;
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// --- Заголовок ---
display.setTextSize(1);
display.setCursor(0, 0);
display.print(F(" PUMP CONTROLLER v8"));
// --- Разделитель ---
display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE);
// --- Состояния насосов ---
display.setCursor(0, 14);
display.print(F("PUMP 1 : "));
display.print(p1.stableState ? F("RUNNING") : F("STOPPED"));
display.setCursor(0, 24);
display.print(F("PUMP 2 : "));
display.print(p2.stableState ? F("RUNNING") : F("STOPPED"));
display.setCursor(0, 34);
display.print(F("BACKUP : "));
display.print(pE.stableState ? F("ACTIVE ") : F("STANDBY"));
// --- Разделитель ---
display.drawFastHLine(0, 44, SCREEN_WIDTH, SSD1306_WHITE);
// --- IP / статус WiFi ---
display.setCursor(0, 47);
if (WiFi.status() == WL_CONNECTED) {
display.print(WiFi.localIP().toString());
} else {
display.print(F("WiFi: NO CONNECTION"));
}
// --- Строка статуса ---
bool alarm = (!p1.stableState && !p2.stableState);
bool emergency = pE.stableState;
display.setCursor(0, 56);
if (!systemReady) {
display.print(F("INIT..."));
} else if (alarm) {
// Мигание каждые 500 мс
if ((millis() / 500) % 2 == 0) {
display.fillRect(0, 54, SCREEN_WIDTH, 10, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
}
display.print(F("!! ALARM: NO PUMPS !!"));
display.setTextColor(SSD1306_WHITE);
} else if (emergency) {
display.print(F("WARN: BACKUP RUNNING"));
} else {
display.print(F("STATUS: OK"));
}
display.display();
}
// ============================================================
// HTTPS POST
// ============================================================
// Вытаскивает имя хоста из URL вида https://host[:port][/path] —
// оно нужно, чтобы разрешить адрес заранее с собственным таймаутом.
// Порт отбрасывается: соединение всё равно устанавливает HTTPClient по URL.
// Форма user@host не поддерживается — в webhook-адресах не встречается.
static bool parseWebhookHost(const char* url, String &host) {
String u(url);
int schemeEnd = u.indexOf("://");
if (schemeEnd < 0) return false;
String scheme = u.substring(0, schemeEnd);
scheme.toLowerCase();
if (scheme != "https" && scheme != "http") return false;
// Границы authority: до первого '/', '?' или '#'
int authStart = schemeEnd + 3;
int authEnd = u.length();
for (int i = authStart; i < (int)u.length(); i++) {
char c = u[i];
if (c == '/' || c == '?' || c == '#') { authEnd = i; break; }
}
String auth = u.substring(authStart, authEnd);
int colon = auth.indexOf(':');
if (colon >= 0) auth = auth.substring(0, colon);
if (auth.length() == 0) return false;
host = auth;
return true;
}
void sendPostWebhook(String eventName, String status) {
if (settings.webhookUrl[0] == '\0') {
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
return;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println(F("[HTTP] Нет Wi-Fi"));
return;
}
const unsigned long started = millis();
String host;
if (!parseWebhookHost(settings.webhookUrl, host)) {
Serial.println("[HTTP] Не удалось разобрать URL: " + String(settings.webhookUrl));
return;
}
// --- Шаг 1: DNS с явным лимитом ---
// WiFiClientSecure::connect(name, port) резолвит имя без таймаута и способен
// подвесить loop() примерно на 10 с. Разрешаем сами, с бюджетом; результат
// ложится в кэш lwIP, поэтому внутренний резолв дальше уже мгновенный
// (и SNI по-прежнему передаётся, так как HTTPClient коннектится по имени).
IPAddress ip;
if (!WiFi.hostByName(host.c_str(), ip, WEBHOOK_DNS_BUDGET_MS)) {
Serial.printf("[HTTP] DNS не ответил за %lu мс: %s\n",
WEBHOOK_DNS_BUDGET_MS, host.c_str());
return;
}
// --- Шаг 2: остаток бюджета на TCP + TLS + чтение ответа ---
unsigned long elapsed = millis() - started;
if (elapsed >= WEBHOOK_BUDGET_MS) {
Serial.println(F("[HTTP] Бюджет исчерпан на резолве DNS"));
return;
}
const uint16_t phaseBudget = (uint16_t)(WEBHOOK_BUDGET_MS - elapsed);
yield(); // отдать время стеку Wi-Fi перед блокирующей фазой
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(phaseBudget);
HTTPClient http;
http.setTimeout(phaseBudget); // ограничивает и connect+TLS, и чтение ответа
http.setReuse(false); // не держать TLS-сокет после отправки
Serial.print(F("[HTTP] POST: "));
Serial.println(settings.webhookUrl);
if (!http.begin(client, settings.webhookUrl)) {
Serial.println(F("[HTTP] begin() не удался"));
return;
}
http.addHeader("Content-Type", "application/json");
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
// в JSON не требуется — сломать тело запроса ему нечем.
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status +
"\",\"device\":\"" + String(settings.deviceName) + "\"}";
int code = http.POST(body);
if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started);
else Serial.printf("[HTTP] Ошибка: %s (%lu мс)\n",
http.errorToString(code).c_str(), millis() - started);
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;
}
// ============================================================
// Дребезг + обновление стабильного состояния
// ============================================================
void updatePumpState(Pump &p) {
bool currentReading = digitalRead(p.pin);
if (currentReading != p.lastReading) {
p.lastDebounceTime = millis();
}
if ((millis() - p.lastDebounceTime) > debounceDelay) {
bool logicalState = (currentReading == LOW);
if (logicalState != p.stableState) {
p.stableState = logicalState;
Serial.printf("[SENSOR] %s -> %s\n", p.name.c_str(), p.stableState ? "РАБОТАЕТ" : "СТОИТ");
}
}
p.lastReading = currentReading;
}
// ============================================================
// HTML интерфейс (без изменений логики, только IP-адрес актуален)
// ============================================================
String getHTML() {
bool isAlarm = (!p1.stableState && !p2.stableState);
String html = F("<!DOCTYPE html><html lang='ru'><head><meta charset='UTF-8'>");
html += "<link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><circle cx=%2250%22 cy=%2250%22 r=%2245%22 fill=%22%23070b12%22 stroke=%22%232ea043%22 stroke-width=%228%22/><circle cx=%2250%22 cy=%2250%22 r=%2220%22 fill=%22none%22 stroke=%22%232ea043%22 stroke-width=%226%22/><line x1=%2250%22 y1=%225%22 x2=%2250%22 y2=%2222%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2250%22 y1=%2278%22 x2=%2250%22 y2=%2295%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%225%22 y1=%2250%22 x2=%2222%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2278%22 y1=%2250%22 x2=%2295%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/></svg>'>";
html += F("<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Мониторинг Насосов</title>"
"<style>"
"@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Unbounded:wght@400;700&display=swap');"
"*{box-sizing:border-box;margin:0;padding:0}"
"body{font-family:'JetBrains Mono',monospace;background:#070b12;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}"
".dash{background:#070b12;border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:28px 24px;width:100%;max-width:520px;position:relative;overflow:hidden}"
".dash::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 80% 20%,rgba(20,80,160,0.18) 0%,transparent 70%),radial-gradient(ellipse 40% 30% at 10% 80%,rgba(10,120,80,0.12) 0%,transparent 60%);pointer-events:none}"
".grid-bg{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.025) 1px,transparent 1px);background-size:40px 40px;pointer-events:none}"
".header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px;position:relative;z-index:1}"
".sys-label{font-family:'Unbounded',monospace;font-size:9px;letter-spacing:0.2em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:4px}"
".title{font-family:'Unbounded',monospace;font-size:17px;font-weight:700;color:#e8edf5;letter-spacing:0.02em}"
".refresh-badge{display:flex;align-items:center;gap:6px;font-size:10px;color:rgba(255,255,255,0.3);letter-spacing:0.08em;margin-top:6px}"
".pulse-dot{width:6px;height:6px;border-radius:50%;background:#2ea043;animation:livePulse 2s ease-in-out infinite}"
"@keyframes livePulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}}"
".time-block{text-align:right}.time-val{font-size:20px;font-weight:700;color:rgba(255,255,255,0.55);letter-spacing:0.05em}.date-val{font-size:10px;color:rgba(255,255,255,0.2);letter-spacing:0.1em;margin-top:2px}"
".pumps-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;position:relative;z-index:1}"
".pump-card{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:14px 12px;position:relative;overflow:hidden}"
".pump-card.active{border-color:rgba(46,160,67,0.4);background:rgba(46,160,67,0.05)}"
".pump-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,#2ea043,transparent);animation:scanLine 3s linear infinite}"
".pump-card.emergency.active{border-color:rgba(218,54,51,0.5);background:rgba(218,54,51,0.06)}"
".pump-card.emergency.active::after{background:linear-gradient(90deg,transparent,#da3633,transparent)}"
"@keyframes scanLine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}"
".pump-type{font-size:8px;letter-spacing:0.18em;color:rgba(255,255,255,0.22);text-transform:uppercase;margin-bottom:8px}.pump-type.emerg{color:rgba(218,54,51,0.45)}"
".pump-icon{width:32px;height:32px;margin-bottom:8px}.pump-name{font-size:11px;font-weight:500;color:#c9d1d9;margin-bottom:10px;line-height:1.4}"
".pump-status{display:flex;align-items:center;gap:6px}.led{width:7px;height:7px;border-radius:50%;flex-shrink:0}"
".led.on{background:#2ea043;box-shadow:0 0 6px rgba(46,160,67,0.8);animation:ledGlow 2s ease-in-out infinite}.led.on.emerg{background:#da3633;box-shadow:0 0 6px rgba(218,54,51,0.8)}.led.off{background:rgba(255,255,255,0.12)}"
"@keyframes ledGlow{0%,100%{opacity:1}50%{opacity:0.55}}.stext{font-size:9px;letter-spacing:0.1em;text-transform:uppercase;font-weight:500}"
".stext.on{color:#2ea043}.stext.on.emerg{color:#da3633}.stext.off{color:rgba(255,255,255,0.2)}"
".meter{margin-top:10px;height:3px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}.mfill{height:100%;border-radius:2px;background:#2ea043;transition:width 1s ease}.mfill.emerg{background:#da3633}"
".bottom-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;position:relative;z-index:1}"
".spanel{border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}.spanel.ok{background:rgba(35,134,54,0.12);border:1px solid rgba(46,160,67,0.3)}"
".spanel.crit{background:rgba(248,81,73,0.08);border:1px solid rgba(248,81,73,0.3)}"
".sicon{width:26px;height:26px;flex-shrink:0}"
".scode{font-size:8px;letter-spacing:0.15em;text-transform:uppercase;margin-bottom:3px}.scode.ok{color:#2ea043}.scode.crit{color:#f85149}"
".smsg{font-size:11px;font-weight:500}.smsg.ok{color:#c9d1d9}.smsg.crit{color:#f85149}"
".cpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
".clabel{font-size:8px;letter-spacing:0.15em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:8px}"
".cbtn{width:100%;padding:7px 0;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
".cbtn:hover{background:rgba(255,255,255,0.1)}"
".wh-row{margin-top:10px;position:relative;z-index:1}"
".wh-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
".wh-url{font-size:9px;color:rgba(255,255,255,0.35);word-break:break-all;margin-bottom:8px}"
".wh-input{width:100%;padding:6px 8px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px}"
".cbtn-save{width:100%;padding:7px 0;background:rgba(46,160,67,0.15);border:1px solid rgba(46,160,67,0.3);border-radius:6px;color:#2ea043;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
".emg-row{margin-top:10px;position:relative;z-index:1}"
".emg-panel{background:rgba(248,81,73,0.06);border:1px solid rgba(248,81,73,0.25);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}"
".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'>"
"<div><div class='sys-label'>SYS · PUMP_CTRL</div>"
"<div class='title'>Мониторинг<br>Насосов</div>"
"<div class='refresh-badge'><div class='pulse-dot'></div>LIVE · AUTO</div></div>"
"<div class='time-block'><div class='time-val' id='clk'>--:--:--</div><div class='date-val' id='dt'></div></div>"
"</div>");
html += "<div class='pumps-grid'>";
// Насос 1
html += "<div class='pump-card" + String(p1.stableState ? " active" : "") + "'>";
html += F("<div class='pump-type'>Насос</div>"
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"</svg>");
html += "<div class='pump-name'>Основной 1</div><div class='pump-status'><div class='led " + String(p1.stableState ? "on" : "off") + "'></div><div class='stext " + String(p1.stableState ? "on" : "off") + "'>" + String(p1.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p1.stableState ? "80" : "0") + "%'></div></div></div>";
// Насос 2
html += "<div class='pump-card" + String(p2.stableState ? " active" : "") + "'>";
html += F("<div class='pump-type'>Насос</div>"
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"</svg>");
html += "<div class='pump-name'>Основной 2</div><div class='pump-status'><div class='led " + String(p2.stableState ? "on" : "off") + "'></div><div class='stext " + String(p2.stableState ? "on" : "off") + "'>" + String(p2.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p2.stableState ? "80" : "0") + "%'></div></div></div>";
// Резерв
html += "<div class='pump-card emergency" + String(pE.stableState ? " active" : "") + "'>";
html += F("<div class='pump-type emerg'>Резерв</div>"
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
"<path d='M16 3 L28 27 L4 27 Z' stroke='currentColor' stroke-width='1.5' fill='none'/>"
"<line x1='16' y1='12' x2='16' y2='20' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
"<circle cx='16' cy='23' r='1.5' fill='currentColor'/>"
"</svg>");
html += "<div class='pump-name'>Резерв</div><div class='pump-status'><div class='led " + String(pE.stableState ? "on emerg" : "off") + "'></div><div class='stext " + String(pE.stableState ? "on emerg" : "off") + "'>" + String(pE.stableState ? "Активен" : "Ожидание") + "</div></div><div class='meter'><div class='mfill" + String(pE.stableState ? " emerg" : "") + "' style='width:" + String(pE.stableState ? "55" : "0") + "%'></div></div></div>";
html += "</div><div class='bottom-row'>";
if (isAlarm) {
html += "<div class='spanel crit'><svg class='sicon' viewBox='0 0 26 26' fill='none'><path d='M13 3 L23 21 L3 21 Z' stroke='#f85149' stroke-width='1.5' fill='rgba(248,81,73,0.1)'/><line x1='13' y1='10' x2='13' y2='16' stroke='#f85149' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode crit'>CRITICAL · 0xFF</div><div class='smsg crit'>Нет подачи!</div></div></div>";
} else {
html += "<div class='spanel ok'><svg class='sicon' viewBox='0 0 26 26' fill='none'><circle cx='13' cy='13' r='10' stroke='#2ea043' stroke-width='1.5'/><path d='M8 13 L11.5 16.5 L18 10' stroke='#2ea043' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode ok'>SYS_OK · 0x00</div><div class='smsg ok'>Система: норма</div></div></div>";
}
html += "<div class='cpanel'><div class='clabel'>Управление</div><form action='/reset_wifi' method='POST' style='margin:0'><button type='submit' class='cbtn'>Сбросить Wi-Fi</button></form></div></div>";
// --- Состояние реле ---
html += "<div class='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>");
if (!settingsFsReady) {
html += F("<div class='smsg crit' style='margin-bottom:8px'>"
"Файловая система недоступна — настройки не переживут перезагрузку!</div>");
}
html += "<div class='wh-url'>Webhook: " +
String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "</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(settings.deviceName) +
"' 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(settings.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(settings.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>";
}
html += F("<script>"
"let lastS = '';"
"function check(){"
"fetch('/status').then(r=>r.json()).then(data=>{"
"let s = JSON.stringify(data);"
"if(lastS !== '' && lastS !== s) location.reload();"
"lastS = s;"
"}).catch(()=>{});"
"}"
"function tick(){"
"var n=new Date();"
"var h=('0'+n.getHours()).slice(-2), m=('0'+n.getMinutes()).slice(-2), s=('0'+n.getSeconds()).slice(-2);"
"document.getElementById('clk').textContent=h+':'+m+':'+s;"
"var mo=['ЯНВ','ФЕВ','МАР','АПР','МАЙ','ИЮН','ИЮЛ','АВГ','СЕН','ОКТ','НОЯ','ДЕК'];"
"document.getElementById('dt').textContent=n.getDate()+' '+mo[n.getMonth()]+' '+n.getFullYear();"
"}"
"tick(); setInterval(tick,1000); setInterval(check,2000);"
"</script></div></body></html>");
return html;
}
// ============================================================
// SETUP
// ============================================================
void setup() {
Serial.begin(115200);
delay(500);
Serial.println(F("\n\n--- СТАРТ КОНТРОЛЛЕРА v8 ---"));
// I2C: SDA=D1, SCL=D2 (по умолчанию для Wemos D1 Mini)
Wire.begin(D1, D2);
// Инит OLED
if (display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
oledOK = true;
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 20);
display.print(F("PUMP CTRL v8"));
display.setCursor(20, 34);
display.print(F("Starting..."));
display.display();
Serial.println(F("[OLED] Дисплей OK"));
} else {
Serial.println(F("[OLED] Дисплей не найден! Проверь адрес (0x3C/0x3D) и провода."));
}
// Диагностика флеша. Если «размер по настройке IDE» больше физического, то
// сектор EEPROM оказывается за пределами микросхемы и запись молча теряется —
// выглядит это ровно как «настройки не сохраняются».
uint32_t flashIde = ESP.getFlashChipSize();
uint32_t flashReal = ESP.getFlashChipRealSize();
Serial.printf("[FLASH] по настройке IDE: %u Б, физически: %u Б -> %s\n",
flashIde, flashReal,
flashIde == flashReal ? "совпадает" : "!!! НЕ СОВПАДАЕТ !!!");
Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n",
ESP.getSketchSize(), ESP.getFreeSketchSpace());
// Где лежит файловая система. Если область выходит за размер микросхемы,
// LittleFS не смонтируется и настройки сохранять будет некуда.
Serial.printf("[FLASH] файловая система: смещение 0x%06X, размер %u Б%s\n",
FS_PHYS_ADDR, FS_PHYS_SIZE,
FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal
? "" : " <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!");
if (flashIde != flashReal) {
Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует"));
Serial.println(F("[FLASH] микросхеме. Выберите верный в Tools -> Flash Size,"));
Serial.println(F("[FLASH] иначе настройки сохраняться не будут."));
}
if (FS_PHYS_SIZE == 0) {
Serial.println(F("[FLASH] ВНИМАНИЕ: в Tools -> Flash Size выбрано FS:none."));
Serial.println(F("[FLASH] Нужен вариант с ненулевой файловой системой."));
}
settingsBegin();
pinMode(p1.pin, INPUT_PULLUP);
pinMode(p2.pin, INPUT_PULLUP);
pinMode(pE.pin, 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);
setRelay(OUT_ALARM_MAIN, false);
setRelay(OUT_ALARM_EMERGENCY, false);
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
// Web-сервер
server.on("/", HTTP_GET, []() {
server.send(200, "text/html", getHTML());
});
server.on("/status", HTTP_GET, []() {
String json = "{";
json += "\"p1\":" + String(p1.stableState ? "1" : "0") + ",";
json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ",";
json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ",";
json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ",";
json += "\"rEmg\":" + String(relayEmergOn ? "1" : "0");
json += "}";
server.send(200, "application/json", json);
});
server.on("/reset_wifi", HTTP_POST, []() {
server.send(200, "text/plain", "WiFi Reset. Rebooting...");
delay(2000);
WiFiManager wm; wm.resetSettings(); ESP.restart();
});
// Настройки одной формой. Пустое или неверное поле означает «не менять» —
// так частично заполненная форма не сбрасывает остальные параметры.
server.on("/set_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(settings.webhookUrl, WEBHOOK_MAX_LEN + 1);
changed = true;
}
}
if (server.hasArg("device")) {
String v = sanitizeDeviceName(server.arg("device"));
if (v.length() > 0 && v != String(settings.deviceName)) {
v.toCharArray(settings.deviceName, DEVICE_MAX_LEN + 1);
changed = true;
}
}
if (server.hasArg("alarm_delay")) {
uint16_t v;
if (parseDelaySec(server.arg("alarm_delay"), v) && v != settings.alarmDelaySec) {
settings.alarmDelaySec = v;
changed = true;
}
}
if (server.hasArg("emerg_delay")) {
uint16_t v;
if (parseDelaySec(server.arg("emerg_delay"), v) && v != settings.emergDelaySec) {
settings.emergDelaySec = v;
changed = true;
}
}
if (changed) settingsSave();
else Serial.println(F("[SET] Изменений нет, запись пропущена"));
server.sendHeader("Location", "/");
server.send(303);
});
// WiFiManager с заставкой на OLED
if (oledOK) {
display.clearDisplay();
display.setCursor(0, 10);
display.print(F("WiFi: connecting..."));
display.setCursor(0, 24);
display.print(F("AP: "));
display.print(config_ap_name);
display.setCursor(0, 38);
display.print(F("Connect & open:"));
display.setCursor(0, 48);
display.print(F("192.168.4.1"));
display.display();
}
WiFiManager wm;
wm.setConfigPortalTimeout(120);
if (wm.autoConnect(config_ap_name)) {
server.begin();
Serial.println("[WIFI] Подключено. IP: " + WiFi.localIP().toString());
if (oledOK) {
display.clearDisplay();
display.setCursor(0, 16);
display.print(F("WiFi: CONNECTED"));
display.setCursor(0, 30);
display.print(F("IP: "));
display.print(WiFi.localIP().toString());
display.display();
delay(2000);
}
} else {
Serial.println(F("[WIFI] Работа в автономном режиме."));
if (oledOK) {
display.clearDisplay();
display.setCursor(0, 16);
display.print(F("WiFi: NO CONNECTION"));
display.setCursor(0, 30);
display.print(F("Offline mode active"));
display.display();
delay(2000);
}
}
Serial.println(F("[SYSTEM] Инициализация завершена."));
}
// ============================================================
// LOOP
// ============================================================
void loop() {
server.handleClient();
updatePumpState(p1);
updatePumpState(p2);
updatePumpState(pE);
if (!systemReady) {
if (millis() > debounceDelay + 500) {
systemReady = true;
Serial.println(F("[SYSTEM] Автоматика готова."));
}
// Обновляем дисплей даже во время инита
if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) {
updateDisplay();
lastDisplayUpdate = millis();
}
return;
}
// --- Основная авария ---
// Выдержка одна на реле и вебхук: при settings.alarmDelaySec = 0 условие выполняется
// на следующей же итерации, то есть реакция мгновенная.
bool bothStopped = (!p1.stableState && !p2.stableState);
if (bothStopped) {
if (!mainAlarmPending) {
mainAlarmPending = true;
mainAlarmStartTime = millis();
} else if (millis() - mainAlarmStartTime >= (unsigned long)settings.alarmDelaySec * 1000UL) {
setRelay(OUT_ALARM_MAIN, true);
if (!mainAlarmSent) {
Serial.println(F("[ALERT] Авария основных насосов"));
sendPostWebhook("MAIN PUMPS", "ALARM");
mainAlarmSent = true;
}
}
} else {
setRelay(OUT_ALARM_MAIN, false);
mainAlarmPending = false;
if (mainAlarmSent) {
Serial.println(F("[INFO] Восстановление: основные насосы OK"));
sendPostWebhook("MAIN PUMPS", "OK");
mainAlarmSent = false;
}
}
// --- Резервный насос ---
if (pE.stableState) {
if (!emergPending) {
emergPending = true;
emergStartTime = millis();
} else if (millis() - emergStartTime >= (unsigned long)settings.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;
}
}
// --- Обновление дисплея раз в секунду ---
if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) {
updateDisplay();
lastDisplayUpdate = millis();
}
delay(10);
}