Compare commits
28
Commits
d1f96007de
..
master
+22
@@ -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,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).
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
build/
|
||||||
|
.build/
|
||||||
|
*.bin
|
||||||
|
*.elf
|
||||||
|
*.hex
|
||||||
|
*.map
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# Контроллер насосов 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 во время загрузки, поэтому реле не остаются включёнными на всё время
|
||||||
|
> старта.
|
||||||
|
|
||||||
|
Оговорки, которые лучше знать заранее:
|
||||||
|
|
||||||
|
* **Оба реле кратковременно моргают при каждой перезагрузке прибора** — и при
|
||||||
|
подаче питания, и при заливке прошивки. Так и есть, это нормально: выходы
|
||||||
|
сидят на служебных пинах платы, и пока прошивка не запустилась, уровнем на них
|
||||||
|
управляет не она. Автоматика в этот момент ещё не работает, ложных уведомлений
|
||||||
|
моргание не вызывает.
|
||||||
|
* Если моргание окажется недопустимым (например, сирена успевает пискнуть) —
|
||||||
|
ставится RC-цепочка на вход релейного модуля. Прошивку менять не нужно.
|
||||||
|
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
|
||||||
|
проверки монтажа.
|
||||||
|
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
|
||||||
|
Если это критично — снимайте нагрузку с **нормально замкнутого (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. Экран
|
||||||
|
|
||||||
|
```
|
||||||
|
SUPPLY OK
|
||||||
|
────────────────────────
|
||||||
|
P1:RUN P2:OFF BK:OFF
|
||||||
|
ALARM:OFF BACKUP:OFF
|
||||||
|
────────────────────────
|
||||||
|
192.168.1.128
|
||||||
|
```
|
||||||
|
|
||||||
|
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
|
||||||
|
|
||||||
|
| Надпись | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `SUPPLY OK` | Норма, подача идёт |
|
||||||
|
| `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
|
||||||
|
| `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
|
||||||
|
| `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
|
||||||
|
| `STARTING` | Идёт запуск, первые ~4 секунды |
|
||||||
|
|
||||||
|
Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
|
||||||
|
основные, `BK` — резерв.
|
||||||
|
|
||||||
|
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
|
||||||
|
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
|
||||||
|
|
||||||
|
Нижняя строка — адрес в сети или `no network`, если связи нет.
|
||||||
|
|
||||||
|
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Панель в браузере
|
||||||
|
|
||||||
|
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
|
||||||
|
|
||||||
|
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
|
||||||
|
лампы датчиков и выходов, в самом низу свёрнутые настройки.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Так панель выглядит в норме. Здесь блок настроек раскрыт; обычно он свёрнут
|
||||||
|
в одну строку и не отвлекает.
|
||||||
|
|
||||||
|
### 5.1 Главная плашка
|
||||||
|
|
||||||
|
| Плашка | Цвет | Что значит |
|
||||||
|
|---|---|---|
|
||||||
|
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
|
||||||
|
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
|
||||||
|
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
|
||||||
|
| **Запуск** | серый | Первые ~4 секунды после включения |
|
||||||
|
|
||||||
|
Под заголовком — уточнение: какой именно насос работает, или «подачу держит
|
||||||
|
резерв», если основные стоят, а резерв качает.
|
||||||
|
|
||||||
|
Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
|
||||||
|
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
||||||
|
|
||||||
|
**Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
|
||||||
|
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
|
||||||
|
|
||||||
|
### 5.2 Лампы
|
||||||
|
|
||||||
|
Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
|
||||||
|
или «ожидание».
|
||||||
|
|
||||||
|
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
|
||||||
|
«разомкнут». Это то, что прибор реально подал на реле.
|
||||||
|
|
||||||
|
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
|
||||||
|
проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
||||||
|
|
||||||
|
### 5.3 Настройки
|
||||||
|
|
||||||
|
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
|
||||||
|
и не отвлекают.
|
||||||
|
|
||||||
|
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
|
||||||
|
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
|
||||||
|
испортит.
|
||||||
|
|
||||||
|
| Поле | Что задаёт | Допустимо | По умолчанию |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
|
||||||
|
| **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
||||||
|
| **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
|
||||||
|
| **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
|
||||||
|
|
||||||
|
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
|
||||||
|
секретный ключ, и выводить его на экран, который видно всем в помещении, не
|
||||||
|
нужно. Чтобы сменить адрес — введите новый целиком.
|
||||||
|
|
||||||
|
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
|
||||||
|
(см. раздел 7).
|
||||||
|
|
||||||
|
Настройки сохраняются в файл во внутренней памяти прибора, переживают
|
||||||
|
отключение питания и смену сети Wi-Fi.
|
||||||
|
|
||||||
|
> Если панель показывает красное предупреждение «Память недоступна» или
|
||||||
|
> настройки пропадают после перезагрузки — причина в настройке `Flash Size`
|
||||||
|
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
|
||||||
|
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
|
||||||
|
> 2 МБ приводит ровно к такому симптому.
|
||||||
|
|
||||||
|
> Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
|
||||||
|
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
||||||
|
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
|
||||||
|
|
||||||
|
**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
|
||||||
|
уведомления отключены», сообщения отправляться не будут, всё остальное работает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Уведомления на сервер
|
||||||
|
|
||||||
|
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
|
||||||
|
|
||||||
|
В сообщении приходит **та же фраза, что показывает панель** — читать можно
|
||||||
|
без расшифровки кодов:
|
||||||
|
|
||||||
|
| Ситуация | Текст в сообщении |
|
||||||
|
|---|---|
|
||||||
|
| Оба основных стоят, резерв качает | «Насосы стоят, подачу держит резерв» |
|
||||||
|
| Оба основных стоят, резерва нет | «Насосы стоят, подачи нет» |
|
||||||
|
| Работает резерв, основные в порядке | «Работает резерв, основные насосы в работе» |
|
||||||
|
| Работа восстановлена | «Подача есть, работает насос 1» |
|
||||||
|
|
||||||
|
Рядом с текстом передаётся служебное поле для автоматики: `MAIN PUMPS` или
|
||||||
|
`EMERGENCY PUMP` и `ALARM` / `OK` / `STARTED` / `STOPPED`.
|
||||||
|
|
||||||
|
Уведомления отправляются один раз на каждое изменение, а не повторяются.
|
||||||
|
Если в момент события связи не было — уведомление **не будет** отправлено
|
||||||
|
позже, оно теряется.
|
||||||
|
|
||||||
|
На отправку отводится не больше **3 секунд**. Если сервер недоступен или
|
||||||
|
отвечает медленно, прибор прекращает попытку и продолжает работу: экран и
|
||||||
|
кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не
|
||||||
|
дожидаясь сервера.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Сброс настроек Wi-Fi
|
||||||
|
|
||||||
|
Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет.
|
||||||
|
|
||||||
|
**Если сеть пропала или сменился пароль** — ничего делать не нужно: при
|
||||||
|
перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа
|
||||||
|
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
|
||||||
|
|
||||||
|
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
|
||||||
|
**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
|
||||||
|
доступа.
|
||||||
|
|
||||||
|
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
|
||||||
|
прибор по питанию. Не найдя сеть, он откроет портал настройки.
|
||||||
|
|
||||||
|
Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при
|
||||||
|
сбросе Wi-Fi **не стираются**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Задержки срабатывания
|
||||||
|
|
||||||
|
Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и
|
||||||
|
остановки:
|
||||||
|
|
||||||
|
| Событие | Задержка |
|
||||||
|
|---|---|
|
||||||
|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
|
||||||
|
| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
|
||||||
|
| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
|
||||||
|
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
|
||||||
|
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
|
||||||
|
|
||||||
|
Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
|
||||||
|
реле и на уведомление** — отдельно задержать только уведомление нельзя.
|
||||||
|
|
||||||
|
Задержка `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. Замените модуль либо потребуйте правку прошивки |
|
||||||
|
| Реле моргают при перезагрузке или заливке прошивки | Нормально, см. 2.3. Выходы на служебных пинах платы; пока прошивка не стартовала, уровнем управляет не она. Уведомления при этом не отправляются |
|
||||||
|
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
|
||||||
|
| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
||||||
|
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
|
||||||
|
|
||||||
|
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
||||||
|
(Arduino IDE → Инструменты → Монитор порта).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Безопасность
|
||||||
|
|
||||||
|
Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может
|
||||||
|
открыть панель, сбросить Wi-Fi, изменить адрес уведомлений и **увеличить
|
||||||
|
выдержки аварии**, то есть замедлить реакцию сигнализации. Устанавливайте прибор
|
||||||
|
в закрытом технологическом сегменте сети, не в гостевом Wi-Fi.
|
||||||
|
|
||||||
|
Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты.
|
||||||
|
Он не заменяет штатную автоматику защиты насосной станции. Кроме того, схема не
|
||||||
|
отказобезопасна: обесточенный контроллер сигнала аварии не подаёт (см. 2.3).
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
# Pump Controller v8.2 (OLED)
|
||||||
|
|
||||||
|
Контроллер мониторинга насосной станции на **Wemos D1 Mini (ESP8266)**.
|
||||||
|
|
||||||
|
Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие
|
||||||
|
контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в
|
||||||
|
локальной сети и отправляет события на HTTPS-webhook (n8n).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Состав репозитория
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `pump_controller_8_2_OLED_DONE.ino` | Логика прошивки |
|
||||||
|
| `web_page.h` | HTML веб-панели в PROGMEM (см. 7.7 — вынесен не для красоты) |
|
||||||
|
| `README.md` | Техническая документация (этот файл) |
|
||||||
|
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||||||
|
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
||||||
|
| `docs/img/` | Снимки экрана для документации |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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); }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Что происходит при перезагрузке на самом деле
|
||||||
|
|
||||||
|
Проверено на собранном приборе: **при перезагрузке оба реле кратковременно
|
||||||
|
моргают.** Признано некритичным и оставлено как есть.
|
||||||
|
|
||||||
|
Ранее в этом разделе утверждалось, что реле при загрузке молчат — это оказалось
|
||||||
|
неверно, и формулировка исправлена.
|
||||||
|
|
||||||
|
Наиболее вероятный механизм: оба выхода неизбежно сидят на служебных пинах.
|
||||||
|
`GPIO2` (`D4`) — это ещё и `UART1 TX`, и на нём во время загрузки появляется
|
||||||
|
служебный вывод; `GPIO0` (`D3`) — пин выбора режима загрузки, который дёргается
|
||||||
|
схемой сброса. Пока `setup()` не выполнился, уровнем на них управляет не
|
||||||
|
прошивка. Точную причину не измеряли: осциллографа в проверке не было.
|
||||||
|
|
||||||
|
Часть морганий может приходиться на светодиод релейного модуля, а не на
|
||||||
|
фактическое переключение контакта: короткий импульс успевает зажечь индикатор,
|
||||||
|
но не перебросить якорь. Различить можно тестером на контактах реле, если это
|
||||||
|
когда-нибудь станет важно.
|
||||||
|
|
||||||
|
**Почему решение всё равно верное.** Альтернатива — `D8` — не улучшила бы
|
||||||
|
ситуацию, а ухудшила: он подтянут к `LOW`, и реле было бы уверенно **включено
|
||||||
|
всю загрузку**, а не моргнуло бы. Кратковременное моргание предпочтительнее
|
||||||
|
устойчивого ложного сигнала на несколько секунд.
|
||||||
|
|
||||||
|
**Если моргание когда-нибудь станет проблемой** (например, сирена успевает
|
||||||
|
пискнуть): ставится RC-цепочка на вход релейного модуля, чтобы короткие импульсы
|
||||||
|
не проходили, либо модуль с триггером Шмитта на входе. Прошивку менять не
|
||||||
|
потребуется.
|
||||||
|
|
||||||
|
### 2.4 Прочие особенности ESP8266
|
||||||
|
|
||||||
|
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
|
||||||
|
«Резерв» получает бесплатную индикацию на плате.
|
||||||
|
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
|
||||||
|
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
|
||||||
|
Кроме того, оба реле кратковременно моргают при любой перезагрузке (см. 2.3).
|
||||||
|
Косметика, но пугает.
|
||||||
|
* `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","state":"ALARM",
|
||||||
|
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
|
||||||
|
| `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
|
||||||
|
| `status` | **Человеческая фраза**, та же что в панели |
|
||||||
|
| `device` | Имя прибора из настроек |
|
||||||
|
|
||||||
|
Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
|
||||||
|
момент отправки — это те же слова, что видит оператор в панели:
|
||||||
|
|
||||||
|
| Ситуация | `status` |
|
||||||
|
|---|---|
|
||||||
|
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
|
||||||
|
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
|
||||||
|
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
|
||||||
|
| Норма | `Подача есть, работает насос 1` |
|
||||||
|
|
||||||
|
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
|
||||||
|
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
|
||||||
|
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
|
||||||
|
|
||||||
|
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
|
||||||
|
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
|
||||||
|
разошлись бы при первой правке.
|
||||||
|
|
||||||
|
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
|
||||||
|
поэтому экранирование в 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-панель из PROGMEM |
|
||||||
|
| `/status` | `GET` | Живое состояние, JSON |
|
||||||
|
| `/config` | `GET` | Настройки и адрес в сети, JSON |
|
||||||
|
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
||||||
|
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
|
||||||
|
|
||||||
|
### 7.1 Почему страница статическая
|
||||||
|
|
||||||
|
Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
|
||||||
|
**14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
|
||||||
|
лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
|
||||||
|
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
|
||||||
|
|
||||||
|
Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
|
||||||
|
`location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
|
||||||
|
пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
|
||||||
|
трогается.
|
||||||
|
|
||||||
|
Ответы:
|
||||||
|
|
||||||
|
```json
|
||||||
|
/status {"p1":1,"p2":0,"pE":0,"rMain":0,"rEmg":0,"rdy":1,
|
||||||
|
"k":"ok","w":"Подача есть","s":"работает насос 1"}
|
||||||
|
/config {"device":"Nasosnaya_1","alarm":5,"emerg":0,
|
||||||
|
"host":"n8n.example.com","fs":1,"ip":"192.168.1.128"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
|
||||||
|
секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
|
||||||
|
|
||||||
|
### 7.2 Логика статуса и цвета
|
||||||
|
|
||||||
|
Цвета взяты по конвенции сигнальных ламп, а не по вкусу:
|
||||||
|
|
||||||
|
| Состояние | Цвет | Заголовок | Условие |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Запуск | серый | «Запуск» | `rdy = 0` |
|
||||||
|
| Норма | зелёный | «Подача есть» | работает хотя бы один основной |
|
||||||
|
| Внимание | **жёлтый** | «Работает резерв» | работает резерв, основные в порядке |
|
||||||
|
| Авария | красный | «Насосы стоят» | оба основных стоят |
|
||||||
|
|
||||||
|
Резерв намеренно **жёлтый, а не красный**: работающий резерв — предупреждение,
|
||||||
|
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
||||||
|
|
||||||
|
При аварии плашка медленно пульсирует; пульсация отключается при
|
||||||
|
`prefers-reduced-motion`.
|
||||||
|
|
||||||
|
### 7.3 Состояние реле
|
||||||
|
|
||||||
|
`rMain` и `rEmg` — **логическое** состояние из переменных `relayMainOn` /
|
||||||
|
`relayEmergOn`, а не `digitalRead()` с пина. Выходы инверсные, и чтение уровня
|
||||||
|
показало бы на панели обратное действительности.
|
||||||
|
|
||||||
|
Лампа выхода «Авария» — **красная**. В первой версии панели она была зелёной:
|
||||||
|
стиль «включено» был общим для всех ламп, и сработавшая аварийная сигнализация
|
||||||
|
подсвечивалась цветом «всё хорошо».
|
||||||
|
|
||||||
|
### 7.4 Никаких внешних запросов
|
||||||
|
|
||||||
|
Веб-шрифты не подключаются. В первой версии был `@import` с Google Fonts, но
|
||||||
|
сеть на объекте не разрешает внешние имена (раздел 5.1), поэтому шрифты там не
|
||||||
|
загружались никогда — нарисованный макет на месте не появлялся. Сейчас
|
||||||
|
используются системные стеки, а характер задан контрастом кеглей: заголовок
|
||||||
|
статуса `clamp(28px, 8.5vw, 44px)` против меток 11–12 px.
|
||||||
|
|
||||||
|
### 7.5 Подпись состояния во вкладке
|
||||||
|
|
||||||
|
Заголовок вкладки и favicon меняют цвет вместе со статусом: `Насосы стоят ·
|
||||||
|
Nasosnaya_1` с красным кружком. Вкладка, забытая в фоне на телефоне, продолжает
|
||||||
|
сигналить, не требуя переключения на неё.
|
||||||
|
|
||||||
|
### 7.7 Почему разметка лежит в отдельном файле
|
||||||
|
|
||||||
|
`web_page.h` вынесен не ради опрятности, а вынужденно. Генератор прототипов
|
||||||
|
Arduino не понимает сырые строковые литералы (`R"=====( ... )====="`): он находит
|
||||||
|
внутри JavaScript строку `function draw(d){`, принимает её за определение функции
|
||||||
|
C++ и вставляет в начало файла прототип `function draw(d);`. Сборка падает с
|
||||||
|
`error: 'function' does not name a type`, причём директивы `#line` показывают
|
||||||
|
ошибку на строке внутри HTML.
|
||||||
|
|
||||||
|
Заголовочные файлы препроцессор не сканирует, поэтому страница живёт там.
|
||||||
|
|
||||||
|
Важное следствие для проверки: обычный `g++ -fsyntax-only` по `.ino` эту ошибку
|
||||||
|
**не покажет** — он компилирует файл как обычный `.cpp`, минуя препроцессор
|
||||||
|
Arduino. Проверять сборку нужно через `arduino-cli` (раздел 11.1).
|
||||||
|
|
||||||
|
### 7.6 Доступ
|
||||||
|
|
||||||
|
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить адрес
|
||||||
|
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
|
||||||
|
Контроллер рассчитан на изолированный технологический сегмент.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. OLED
|
||||||
|
|
||||||
|
Обновление раз в секунду (`DISPLAY_INTERVAL`).
|
||||||
|
|
||||||
|
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
|
||||||
|
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
|
||||||
|
менялся и ничего не сообщал.
|
||||||
|
|
||||||
|
```
|
||||||
|
NO SUPPLY <- кегль 2, до 10 символов
|
||||||
|
────────────────────────
|
||||||
|
P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
|
||||||
|
ALARM:ON BACKUP:ON <- состояние выходов
|
||||||
|
────────────────────────
|
||||||
|
192.168.1.128
|
||||||
|
```
|
||||||
|
|
||||||
|
| Условие | Слово |
|
||||||
|
|---|---|
|
||||||
|
| `!systemReady` | `STARTING` |
|
||||||
|
| оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
|
||||||
|
| оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
|
||||||
|
| работает резерв, основные в порядке | `BACKUP RUN` |
|
||||||
|
| иначе | `SUPPLY OK` |
|
||||||
|
|
||||||
|
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
|
||||||
|
тащить её во флеш ради четырёх слов не стоит.
|
||||||
|
|
||||||
|
Состояние обоих реле теперь выводится — освободились две строки после
|
||||||
|
объединения датчиков в одну.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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. Сборка и прошивка
|
||||||
|
|
||||||
|
Плата в IDE: **LOLIN(WEMOS) D1 ESP-WROOM-02**, а не `D1 R2 & mini`. У WROOM-02
|
||||||
|
модуль на 2 МБ, и только у этой платы в списке есть нужные варианты `2MB (...)`.
|
||||||
|
`Flash Size` по умолчанию для неё — `2MB (FS:64KB OTA:~992KB)`, он и используется.
|
||||||
|
|
||||||
|
`arduino-cli` отдельно ставить не нужно, он идёт внутри Arduino IDE:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 --warnings all .
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" upload -p COM7 --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 .
|
||||||
|
```
|
||||||
|
|
||||||
|
Монитор порта: **115200 бод**. Если заливка падает с
|
||||||
|
`could not open port 'COM7': PermissionError`, порт держит открытый монитор
|
||||||
|
Arduino IDE — закройте его.
|
||||||
|
|
||||||
|
### 11.1 Проверять сборку только через arduino-cli
|
||||||
|
|
||||||
|
Скетч нельзя надёжно проверить, скормив `.ino` напрямую в `g++`: так
|
||||||
|
пропускается препроцессор Arduino, а именно он ломается на некоторых
|
||||||
|
конструкциях (раздел 7.7). Ошибка, которую `g++ -fsyntax-only` не показывает,
|
||||||
|
на реальной сборке останавливает всё.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -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,877 @@
|
|||||||
|
/*
|
||||||
|
============================================================
|
||||||
|
Контроллер насосов 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
|
||||||
|
// ============================================================
|
||||||
|
/*
|
||||||
|
Верхняя строка отдана главному ответу кеглем 2 (12x16 px) - читается от
|
||||||
|
двери. Прежний постоянный заголовок "PUMP CONTROLLER v8" её занимал, но
|
||||||
|
ничего не сообщал. При аварии строка мигает инверсией раз в 500 мс.
|
||||||
|
|
||||||
|
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не
|
||||||
|
содержит, а тащить её во флеш ради четырёх слов не стоит.
|
||||||
|
|
||||||
|
+--------------------------+
|
||||||
|
| NO SUPPLY | кегль 2, до 10 символов
|
||||||
|
|--------------------------|
|
||||||
|
| P1:OFF P2:OFF BK:RUN | датчики, одна строка
|
||||||
|
| ALARM:ON BACKUP:ON | состояние выходов
|
||||||
|
|--------------------------|
|
||||||
|
| 192.168.1.128 |
|
||||||
|
+--------------------------+
|
||||||
|
|
||||||
|
Слова статуса: STARTING / SUPPLY OK / BACKUP RUN / BACKUP ON / NO SUPPLY
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Реле аварий (модули 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;
|
||||||
|
|
||||||
|
bool alarm = (!p1.stableState && !p2.stableState);
|
||||||
|
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// --- Главный ответ, кегль 2 (12x16 px, до 10 символов) ---
|
||||||
|
// Верхняя строка отдана статусу: прежний заголовок "PUMP CONTROLLER v8"
|
||||||
|
// занимал её постоянно, но никогда не менялся и ничего не сообщал.
|
||||||
|
const __FlashStringHelper* word;
|
||||||
|
if (!systemReady) word = F("STARTING");
|
||||||
|
else if (alarm) word = pE.stableState ? F("BACKUP ON") : F("NO SUPPLY");
|
||||||
|
else if (pE.stableState) word = F("BACKUP RUN");
|
||||||
|
else word = F("SUPPLY OK");
|
||||||
|
|
||||||
|
// При аварии строка мигает инверсией каждые 500 мс
|
||||||
|
bool blink = alarm && ((millis() / 500) % 2 == 0);
|
||||||
|
if (blink) {
|
||||||
|
display.fillRect(0, 0, SCREEN_WIDTH, 18, SSD1306_WHITE);
|
||||||
|
display.setTextColor(SSD1306_BLACK);
|
||||||
|
}
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setCursor(0, 1);
|
||||||
|
display.print(word);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setTextSize(1);
|
||||||
|
|
||||||
|
display.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE);
|
||||||
|
|
||||||
|
// --- Датчики: три поля в одной строке вместо трёх строк ---
|
||||||
|
display.setCursor(0, 25);
|
||||||
|
display.print(F("P1:"));
|
||||||
|
display.print(p1.stableState ? F("RUN") : F("OFF"));
|
||||||
|
display.print(F(" P2:"));
|
||||||
|
display.print(p2.stableState ? F("RUN") : F("OFF"));
|
||||||
|
display.print(F(" BK:"));
|
||||||
|
display.print(pE.stableState ? F("RUN") : F("OFF"));
|
||||||
|
|
||||||
|
// --- Выходы на реле ---
|
||||||
|
display.setCursor(0, 37);
|
||||||
|
display.print(F("ALARM:"));
|
||||||
|
display.print(relayMainOn ? F("ON ") : F("OFF"));
|
||||||
|
display.print(F(" BACKUP:"));
|
||||||
|
display.print(relayEmergOn ? F("ON") : F("OFF"));
|
||||||
|
|
||||||
|
display.drawFastHLine(0, 49, SCREEN_WIDTH, SSD1306_WHITE);
|
||||||
|
|
||||||
|
// --- Адрес в сети ---
|
||||||
|
display.setCursor(0, 54);
|
||||||
|
if (WiFi.status() == WL_CONNECTED) display.print(WiFi.localIP().toString());
|
||||||
|
else display.print(F("no network"));
|
||||||
|
|
||||||
|
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 state) {
|
||||||
|
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");
|
||||||
|
// status — человеческая фраза, та же что в панели: «Насосы стоят, подачу
|
||||||
|
// держит резерв». state оставлен машиночитаемым (ALARM / OK / STARTED /
|
||||||
|
// STOPPED), чтобы сценарий на сервере ветвился по нему, а не разбирал текст.
|
||||||
|
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], фразы кавычек
|
||||||
|
// и обратных слэшей не содержат — экранирование в JSON нигде не нужно.
|
||||||
|
String body = "{\"event\":\"" + eventName +
|
||||||
|
"\",\"state\":\"" + state +
|
||||||
|
"\",\"status\":\"" + statusPhrase() +
|
||||||
|
"\",\"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Словесное описание состояния
|
||||||
|
// ============================================================
|
||||||
|
// Единственное место, где живёт формулировка. Её берут и веб-панель (через
|
||||||
|
// /status), и тело webhook — иначе два набора фраз разошлись бы при первой
|
||||||
|
// же правке. Фразы не содержат кавычек и обратных слэшей, поэтому безопасно
|
||||||
|
// вставляются в JSON без экранирования.
|
||||||
|
|
||||||
|
// Вид состояния: им панель выбирает цвет плашки.
|
||||||
|
static const char* statusKind() {
|
||||||
|
if (!systemReady) return "init";
|
||||||
|
if (!p1.stableState && !p2.stableState) return "flt";
|
||||||
|
if (pE.stableState) return "warn";
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главное слово — крупная строка в панели и начало фразы в уведомлении.
|
||||||
|
static const __FlashStringHelper* statusWord() {
|
||||||
|
if (!systemReady) return F("Запуск");
|
||||||
|
if (!p1.stableState && !p2.stableState) return F("Насосы стоят");
|
||||||
|
if (pE.stableState) return F("Работает резерв");
|
||||||
|
return F("Подача есть");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уточнение — вторая строка в панели и хвост фразы в уведомлении.
|
||||||
|
static const __FlashStringHelper* statusSub() {
|
||||||
|
if (!systemReady) return F("идёт проверка датчиков");
|
||||||
|
if (!p1.stableState && !p2.stableState) {
|
||||||
|
return pE.stableState ? F("подачу держит резерв") : F("подачи нет");
|
||||||
|
}
|
||||||
|
if (pE.stableState) return F("основные насосы в работе");
|
||||||
|
if (p1.stableState && p2.stableState) return F("работают оба насоса");
|
||||||
|
return p1.stableState ? F("работает насос 1") : F("работает насос 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Готовая фраза целиком: «Насосы стоят, подачу держит резерв».
|
||||||
|
static String statusPhrase() {
|
||||||
|
return String(statusWord()) + ", " + String(statusSub());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ВЕБ-ПАНЕЛЬ
|
||||||
|
// ============================================================
|
||||||
|
// Разметка вынесена в web_page.h намеренно, а не для красоты: генератор
|
||||||
|
// прототипов Arduino не понимает сырые строковые литералы и принимает
|
||||||
|
// "function draw(d){" внутри JS за определение функции C++, после чего
|
||||||
|
// вставляет для него прототип и сборка падает. Заголовочные файлы он не
|
||||||
|
// сканирует. Обычный g++ такую ошибку не показывает — она видна только
|
||||||
|
// при сборке через arduino-cli или IDE.
|
||||||
|
#include "web_page.h"
|
||||||
|
;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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_P(200, PSTR("text/html"), PAGE_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Живое состояние. Опрашивается раз в 2 секунды, поэтому держим ответ коротким.
|
||||||
|
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 += "\"rdy\":" + String(systemReady ? "1" : "0") + ",";
|
||||||
|
// Формулировки приходят готовыми, а не собираются в браузере: та же
|
||||||
|
// фраза уходит в webhook, и второй набор строк в JS неизбежно бы разошёлся.
|
||||||
|
json += "\"k\":\"" + String(statusKind()) + "\",";
|
||||||
|
json += "\"w\":\"" + String(statusWord()) + "\",";
|
||||||
|
json += "\"s\":\"" + String(statusSub()) + "\"";
|
||||||
|
json += "}";
|
||||||
|
server.send(200, "application/json", json);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Настройки. Забираются один раз при загрузке страницы.
|
||||||
|
// Webhook отдаётся только именем хоста: полный URL содержит секретный токен,
|
||||||
|
// и выводить его на экран, который видно всем в помещении, незачем.
|
||||||
|
server.on("/config", HTTP_GET, []() {
|
||||||
|
String host;
|
||||||
|
if (!parseWebhookHost(settings.webhookUrl, host)) host = "";
|
||||||
|
|
||||||
|
String json = "{";
|
||||||
|
json += "\"device\":\"" + String(settings.deviceName) + "\",";
|
||||||
|
json += "\"alarm\":" + String(settings.alarmDelaySec) + ",";
|
||||||
|
json += "\"emerg\":" + String(settings.emergDelaySec) + ",";
|
||||||
|
json += "\"host\":\"" + host + "\",";
|
||||||
|
json += "\"fs\":" + String(settingsFsReady ? "1" : "0") + ",";
|
||||||
|
json += "\"ip\":\"" + (WiFi.status() == WL_CONNECTED
|
||||||
|
? WiFi.localIP().toString() : String("нет сети")) + "\"";
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ВЕБ-ПАНЕЛЬ
|
||||||
|
// ============================================================
|
||||||
|
// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё
|
||||||
|
// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой
|
||||||
|
// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча
|
||||||
|
// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных.
|
||||||
|
//
|
||||||
|
// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ
|
||||||
|
// («есть подача или нет») занимает верх страницы и читается с расстояния,
|
||||||
|
// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная
|
||||||
|
// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение,
|
||||||
|
// система ещё справляется), красная «авария» только при остановке основных.
|
||||||
|
//
|
||||||
|
// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние
|
||||||
|
// имена, и @import с Google Fonts там не грузится никогда. Характер задан
|
||||||
|
// контрастом кеглей, а не гарнитурой.
|
||||||
|
|
||||||
|
static const char PAGE_HTML[] PROGMEM = R"=====(<!DOCTYPE html><html lang="ru"><head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Насосная</title><link id="fav" rel="icon" href="data:,">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root{--bg:#0e1216;--sf:#161b21;--ln:#232a32;--ink:#dde3e9;--dim:#8b97a3;
|
||||||
|
--run:#35c46a;--warn:#f0a516;--flt:#e23c2e;--off:#2a323a}
|
||||||
|
body{background:var(--bg);color:var(--ink);min-height:100vh;padding:16px;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
|
||||||
|
font-size:15px;line-height:1.4;display:flex;justify-content:center}
|
||||||
|
.wrap{width:100%;max-width:520px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
.top{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
|
||||||
|
font-size:13px;color:var(--dim)}
|
||||||
|
.top b{font-weight:600;color:var(--ink);font-size:14px}
|
||||||
|
.ip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||||
|
.st{border-radius:14px;padding:20px;border:1px solid var(--ln);background:var(--sf)}
|
||||||
|
.st b{display:block;font-size:clamp(28px,8.5vw,44px);line-height:1.05;
|
||||||
|
letter-spacing:-.02em;font-weight:800}
|
||||||
|
.st span{display:block;margin-top:6px;font-size:14px;color:var(--dim)}
|
||||||
|
.st.ok{background:rgba(53,196,106,.10);border-color:rgba(53,196,106,.35)}
|
||||||
|
.st.ok b{color:var(--run)}
|
||||||
|
.st.warn{background:rgba(240,165,22,.10);border-color:rgba(240,165,22,.4)}
|
||||||
|
.st.warn b{color:var(--warn)}
|
||||||
|
.st.flt{background:rgba(226,60,46,.12);border-color:rgba(226,60,46,.45);
|
||||||
|
animation:beat 1.6s ease-in-out infinite}
|
||||||
|
.st.flt b{color:var(--flt)}
|
||||||
|
@keyframes beat{50%{background:rgba(226,60,46,.26)}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.st.flt{animation:none}}
|
||||||
|
.row{display:grid;gap:8px}
|
||||||
|
.r3{grid-template-columns:repeat(3,1fr)}
|
||||||
|
.r2{grid-template-columns:repeat(2,1fr)}
|
||||||
|
.c{background:var(--sf);border:1px solid var(--ln);border-radius:12px;padding:12px}
|
||||||
|
.c i{display:block;width:13px;height:13px;border-radius:50%;background:var(--off);
|
||||||
|
margin-bottom:10px;transition:background .2s}
|
||||||
|
.c.on i{background:var(--run);box-shadow:0 0 10px rgba(53,196,106,.7)}
|
||||||
|
.c.on.w i{background:var(--warn);box-shadow:0 0 10px rgba(240,165,22,.7)}
|
||||||
|
.c.on.f i{background:var(--flt);box-shadow:0 0 10px rgba(226,60,46,.7)}
|
||||||
|
.c em{font-style:normal;display:block;font-size:11px;letter-spacing:.08em;
|
||||||
|
text-transform:uppercase;color:var(--dim)}
|
||||||
|
.c b{display:block;margin-top:3px;font-size:14px;font-weight:600}
|
||||||
|
details{background:var(--sf);border:1px solid var(--ln);border-radius:12px}
|
||||||
|
summary{padding:12px 14px;cursor:pointer;font-size:14px;font-weight:600;
|
||||||
|
list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
summary::-webkit-details-marker{display:none}
|
||||||
|
summary::after{content:"▾";color:var(--dim)}
|
||||||
|
details[open] summary::after{content:"▴"}
|
||||||
|
.body{padding:0 14px 14px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
label{display:block;font-size:12px;color:var(--dim);margin-bottom:5px}
|
||||||
|
input{width:100%;padding:9px 10px;background:#0f1418;border:1px solid var(--ln);
|
||||||
|
border-radius:8px;color:var(--ink);font:inherit;font-size:14px}
|
||||||
|
input:focus{outline:2px solid rgba(53,196,106,.5);outline-offset:1px}
|
||||||
|
.two{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||||
|
.hint{font-size:12px;color:var(--dim)}
|
||||||
|
button{width:100%;padding:11px;border-radius:8px;border:1px solid;font:inherit;
|
||||||
|
font-size:14px;font-weight:600;cursor:pointer}
|
||||||
|
.save{background:rgba(53,196,106,.14);border-color:rgba(53,196,106,.45);color:var(--run)}
|
||||||
|
.wifi{background:transparent;border-color:var(--ln);color:var(--dim);margin-top:2px}
|
||||||
|
.warnbox{background:rgba(226,60,46,.12);border:1px solid rgba(226,60,46,.4);
|
||||||
|
border-radius:8px;padding:10px;font-size:13px;color:#f2a49c}
|
||||||
|
.hide{display:none}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
|
||||||
|
<div class="top"><b id="dev">—</b><span class="ip" id="ip"></span></div>
|
||||||
|
|
||||||
|
<div class="st init" id="st"><b id="word">Связь…</b><span id="sub">получаем состояние прибора</span></div>
|
||||||
|
|
||||||
|
<div class="row r3">
|
||||||
|
<div class="c" id="l1"><i></i><em>Насос 1</em><b>—</b></div>
|
||||||
|
<div class="c" id="l2"><i></i><em>Насос 2</em><b>—</b></div>
|
||||||
|
<div class="c w" id="lE"><i></i><em>Резерв</em><b>—</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row r2">
|
||||||
|
<div class="c f" id="o1"><i></i><em>Выход «Авария»</em><b>—</b></div>
|
||||||
|
<div class="c w" id="o2"><i></i><em>Выход «Резерв»</em><b>—</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details><summary>Настройки</summary><div class="body">
|
||||||
|
<div class="warnbox hide" id="fsw">Память недоступна — настройки не сохранятся до перезагрузки.</div>
|
||||||
|
<form method="POST" action="/set_config">
|
||||||
|
<div><label for="u">Адрес для уведомлений</label>
|
||||||
|
<input id="u" name="url" type="text" placeholder="оставьте пустым, чтобы не менять">
|
||||||
|
<div class="hint" id="uh" style="margin-top:5px"></div></div>
|
||||||
|
<div style="margin-top:12px"><label for="d">Имя прибора в уведомлениях</label>
|
||||||
|
<input id="d" name="device" maxlength="31">
|
||||||
|
<div class="hint" style="margin-top:5px">Латиница, цифры, дефис и подчёркивание</div></div>
|
||||||
|
<div style="margin-top:12px"><label>Задержка сигнала, секунд</label>
|
||||||
|
<div class="two">
|
||||||
|
<div><input id="a" name="alarm_delay" type="number" min="0" max="3600" aria-label="Задержка аварии">
|
||||||
|
<div class="hint" style="margin-top:5px">аварии</div></div>
|
||||||
|
<div><input id="e" name="emerg_delay" type="number" min="0" max="3600" aria-label="Задержка резерва">
|
||||||
|
<div class="hint" style="margin-top:5px">резерва</div></div></div></div>
|
||||||
|
<button class="save" type="submit" style="margin-top:14px">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/reset_wifi"><button class="wifi" type="submit">Сменить сеть Wi-Fi</button></form>
|
||||||
|
</div></details>
|
||||||
|
|
||||||
|
</div><script>
|
||||||
|
var F={ok:"#35c46a",warn:"#f0a516",flt:"#e23c2e",init:"#8b97a3"};
|
||||||
|
function lamp(id,on,txt){var e=document.getElementById(id);
|
||||||
|
e.classList.toggle("on",!!on);e.querySelector("b").textContent=txt}
|
||||||
|
function fav(c){document.getElementById("fav").href="data:image/svg+xml,"+
|
||||||
|
encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='13' fill='"+c+"'/></svg>")}
|
||||||
|
var dev="";
|
||||||
|
function draw(d){
|
||||||
|
lamp("l1",d.p1,d.p1?"работает":"стоит");
|
||||||
|
lamp("l2",d.p2,d.p2?"работает":"стоит");
|
||||||
|
lamp("lE",d.pE,d.pE?"работает":"ожидание");
|
||||||
|
lamp("o1",d.rMain,d.rMain?"замкнут":"разомкнут");
|
||||||
|
lamp("o2",d.rEmg,d.rEmg?"замкнут":"разомкнут");
|
||||||
|
document.getElementById("st").className="st "+d.k;
|
||||||
|
document.getElementById("word").textContent=d.w;
|
||||||
|
document.getElementById("sub").textContent=d.s;
|
||||||
|
document.title=d.w+(dev?" · "+dev:"");
|
||||||
|
fav(F[d.k])}
|
||||||
|
function poll(){fetch("/status").then(function(r){return r.json()}).then(draw).catch(function(){})}
|
||||||
|
fetch("/config").then(function(r){return r.json()}).then(function(c){
|
||||||
|
dev=c.device;document.getElementById("dev").textContent=c.device;
|
||||||
|
document.getElementById("ip").textContent=c.ip;
|
||||||
|
document.getElementById("d").value=c.device;
|
||||||
|
document.getElementById("a").value=c.alarm;
|
||||||
|
document.getElementById("e").value=c.emerg;
|
||||||
|
document.getElementById("uh").textContent=c.host?"Сейчас: "+c.host:"Не задан — уведомления отключены";
|
||||||
|
if(!c.fs)document.getElementById("fsw").classList.remove("hide")}).catch(function(){});
|
||||||
|
poll();setInterval(poll,2000);
|
||||||
|
</script></body></html>)====="
|
||||||
Reference in New Issue
Block a user