Compare commits
13
Commits
master
..
88c878c1c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c878c1c8 | ||
|
|
635d5e2460 | ||
|
|
9d764ba43f | ||
|
|
f0e8102c5c | ||
|
|
5784e256a7 | ||
|
|
f0844a15af | ||
|
|
b18f0d0ce0 | ||
|
|
56e6bfd098 | ||
|
|
d07a0dd23c | ||
|
|
a7ec85096a | ||
|
|
2deb1b979f | ||
|
|
e4ef065c9b | ||
|
|
1cdcaa9e99 |
@@ -60,18 +60,12 @@ GND). **Сирену, лампу или пускатель напрямую к `
|
|||||||
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
|
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
|
||||||
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
|
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
|
||||||
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
|
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
|
||||||
> HIGH во время загрузки, поэтому реле не остаются включёнными на всё время
|
> HIGH во время загрузки платы, поэтому реле молчат при включении питания.
|
||||||
> старта.
|
|
||||||
|
|
||||||
Оговорки, которые лучше знать заранее:
|
Оговорки, которые лучше знать заранее:
|
||||||
|
|
||||||
* **Оба реле кратковременно моргают при каждой перезагрузке прибора** — и при
|
* Реле «Авария» **щёлкает при каждой заливке прошивки** — схема автосброса USB
|
||||||
подаче питания, и при заливке прошивки. Так и есть, это нормально: выходы
|
дёргает `D3`. Это нормально.
|
||||||
сидят на служебных пинах платы, и пока прошивка не запустилась, уровнем на них
|
|
||||||
управляет не она. Автоматика в этот момент ещё не работает, ложных уведомлений
|
|
||||||
моргание не вызывает.
|
|
||||||
* Если моргание окажется недопустимым (например, сирена успевает пискнуть) —
|
|
||||||
ставится RC-цепочка на вход релейного модуля. Прошивку менять не нужно.
|
|
||||||
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
|
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
|
||||||
проверки монтажа.
|
проверки монтажа.
|
||||||
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
|
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
|
||||||
@@ -112,33 +106,27 @@ OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
|
|||||||
## 4. Экран
|
## 4. Экран
|
||||||
|
|
||||||
```
|
```
|
||||||
SUPPLY OK
|
PUMP CONTROLLER v8
|
||||||
────────────────────────
|
────────────────────────
|
||||||
P1:RUN P2:OFF BK:OFF
|
PUMP 1 : RUNNING
|
||||||
ALARM:OFF BACKUP:OFF
|
PUMP 2 : STOPPED
|
||||||
|
BACKUP : STANDBY
|
||||||
────────────────────────
|
────────────────────────
|
||||||
192.168.1.128
|
192.168.1.42
|
||||||
|
STATUS: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
|
|
||||||
|
|
||||||
| Надпись | Значение |
|
| Надпись | Значение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `SUPPLY OK` | Норма, подача идёт |
|
| `PUMP 1/2 : RUNNING` | Насос работает |
|
||||||
| `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
|
| `PUMP 1/2 : STOPPED` | Насос остановлен |
|
||||||
| `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
|
| `BACKUP : ACTIVE` | Резервный насос запущен |
|
||||||
| `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
|
| `BACKUP : STANDBY` | Резерв в ожидании |
|
||||||
| `STARTING` | Идёт запуск, первые ~4 секунды |
|
| `WiFi: NO CONNECTION` | Нет связи с сетью |
|
||||||
|
| `INIT...` | Идёт запуск (первые ~4 секунды) |
|
||||||
Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
|
| `STATUS: OK` | Норма |
|
||||||
основные, `BK` — резерв.
|
| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание |
|
||||||
|
| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** |
|
||||||
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
|
|
||||||
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
|
|
||||||
|
|
||||||
Нижняя строка — адрес в сети или `no network`, если связи нет.
|
|
||||||
|
|
||||||
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -146,81 +134,57 @@ ALARM:OFF BACKUP:OFF
|
|||||||
|
|
||||||
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
|
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
|
||||||
|
|
||||||
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
|
На странице:
|
||||||
лампы датчиков и выходов, в самом низу свёрнутые настройки.
|
|
||||||
|
|
||||||

|
* три карточки насосов с индикаторами;
|
||||||
|
* панель состояния системы (`SYS_OK` или `CRITICAL`);
|
||||||
|
* **состояние обоих реле** — «Включено» / «Выключено»;
|
||||||
|
* кнопка **Сбросить Wi-Fi**;
|
||||||
|
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
|
||||||
|
|
||||||
Так панель выглядит в норме. Здесь блок настроек раскрыт; обычно он свёрнут
|
Страница обновляется автоматически при изменении состояния насосов или реле.
|
||||||
в одну строку и не отвлекает.
|
|
||||||
|
|
||||||
### 5.1 Главная плашка
|
### 5.1 Состояние реле
|
||||||
|
|
||||||
| Плашка | Цвет | Что значит |
|
Два индикатора показывают, что прибор реально подал на выход:
|
||||||
|---|---|---|
|
|
||||||
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
|
|
||||||
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
|
|
||||||
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
|
|
||||||
| **Запуск** | серый | Первые ~4 секунды после включения |
|
|
||||||
|
|
||||||
Под заголовком — уточнение: какой именно насос работает, или «подачу держит
|
| Индикатор | Значение |
|
||||||
резерв», если основные стоят, а резерв качает.
|
|---|---|
|
||||||
|
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
|
||||||
|
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
|
||||||
|
|
||||||
Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
|
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не
|
||||||
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
||||||
|
|
||||||
**Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
|
### 5.2 Настройки
|
||||||
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
|
|
||||||
|
|
||||||
### 5.2 Лампы
|
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно
|
||||||
|
поправить одну выдержку, не трогая остальное. Неверное значение так же
|
||||||
Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
|
игнорируется, поэтому опечатка ничего не испортит.
|
||||||
или «ожидание».
|
|
||||||
|
|
||||||
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
|
|
||||||
«разомкнут». Это то, что прибор реально подал на реле.
|
|
||||||
|
|
||||||
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
|
|
||||||
проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
|
||||||
|
|
||||||
### 5.3 Настройки
|
|
||||||
|
|
||||||
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
|
|
||||||
и не отвлекают.
|
|
||||||
|
|
||||||
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
|
|
||||||
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
|
|
||||||
испортит.
|
|
||||||
|
|
||||||
| Поле | Что задаёт | Допустимо | По умолчанию |
|
| Поле | Что задаёт | Допустимо | По умолчанию |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
|
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан |
|
||||||
| **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
||||||
| **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
|
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
|
||||||
| **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
|
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
|
||||||
|
|
||||||
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
|
|
||||||
секретный ключ, и выводить его на экран, который видно всем в помещении, не
|
|
||||||
нужно. Чтобы сменить адрес — введите новый целиком.
|
|
||||||
|
|
||||||
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
|
|
||||||
(см. раздел 7).
|
|
||||||
|
|
||||||
Настройки сохраняются в файл во внутренней памяти прибора, переживают
|
Настройки сохраняются в файл во внутренней памяти прибора, переживают
|
||||||
отключение питания и смену сети Wi-Fi.
|
отключение питания и сброс Wi-Fi.
|
||||||
|
|
||||||
> Если панель показывает красное предупреждение «Память недоступна» или
|
> Если панель показывает красное предупреждение «Файловая система недоступна»
|
||||||
> настройки пропадают после перезагрузки — причина в настройке `Flash Size`
|
> или настройки пропадают после перезагрузки — причина в настройке `Flash Size`
|
||||||
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
|
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
|
||||||
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
|
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
|
||||||
> 2 МБ приводит ровно к такому симптому.
|
> 2 МБ приводит ровно к такому симптому.
|
||||||
|
|
||||||
> Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
|
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
|
||||||
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
||||||
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
|
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
|
||||||
|
|
||||||
**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
|
**На новом приборе адрес уведомлений не задан** — панель покажет
|
||||||
уведомления отключены», сообщения отправляться не будут, всё остальное работает.
|
`не задан - уведомления отключены`, сообщения отправляться не будут, всё
|
||||||
|
остальное работает.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -228,18 +192,12 @@ ALARM:OFF BACKUP:OFF
|
|||||||
|
|
||||||
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
|
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
|
||||||
|
|
||||||
В сообщении приходит **та же фраза, что показывает панель** — читать можно
|
| Событие | Что означает |
|
||||||
без расшифровки кодов:
|
|
||||||
|
|
||||||
| Ситуация | Текст в сообщении |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Оба основных стоят, резерв качает | «Насосы стоят, подачу держит резерв» |
|
| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят |
|
||||||
| Оба основных стоят, резерва нет | «Насосы стоят, подачи нет» |
|
| `MAIN PUMPS` / `OK` | Работа восстановлена |
|
||||||
| Работает резерв, основные в порядке | «Работает резерв, основные насосы в работе» |
|
| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос |
|
||||||
| Работа восстановлена | «Подача есть, работает насос 1» |
|
| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился |
|
||||||
|
|
||||||
Рядом с текстом передаётся служебное поле для автоматики: `MAIN PUMPS` или
|
|
||||||
`EMERGENCY PUMP` и `ALARM` / `OK` / `STARTED` / `STOPPED`.
|
|
||||||
|
|
||||||
Уведомления отправляются один раз на каждое изменение, а не повторяются.
|
Уведомления отправляются один раз на каждое изменение, а не повторяются.
|
||||||
Если в момент события связи не было — уведомление **не будет** отправлено
|
Если в момент события связи не было — уведомление **не будет** отправлено
|
||||||
@@ -261,7 +219,7 @@ ALARM:OFF BACKUP:OFF
|
|||||||
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
|
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
|
||||||
|
|
||||||
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
|
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
|
||||||
**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
|
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку
|
||||||
доступа.
|
доступа.
|
||||||
|
|
||||||
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
|
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
|
||||||
@@ -280,15 +238,15 @@ ALARM:OFF BACKUP:OFF
|
|||||||
| Событие | Задержка |
|
| Событие | Задержка |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
|
||||||
| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
|
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с |
|
||||||
| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
|
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с |
|
||||||
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
|
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
|
||||||
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
|
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
|
||||||
|
|
||||||
Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
|
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на
|
||||||
реле и на уведомление** — отдельно задержать только уведомление нельзя.
|
реле и на уведомление** — отдельно задержать только уведомление нельзя.
|
||||||
|
|
||||||
Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
|
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
|
||||||
антидребезга.
|
антидребезга.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -306,9 +264,9 @@ ALARM:OFF BACKUP:OFF
|
|||||||
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
|
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
|
||||||
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
|
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
|
||||||
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
|
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
|
||||||
| Реле моргают при перезагрузке или заливке прошивки | Нормально, см. 2.3. Выходы на служебных пинах платы; пока прошивка не стартовала, уровнем управляет не она. Уведомления при этом не отправляются |
|
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет |
|
||||||
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
|
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
|
||||||
| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
||||||
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
|
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
|
||||||
|
|
||||||
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
||||||
|
|||||||
@@ -12,12 +12,10 @@
|
|||||||
|
|
||||||
| Файл | Назначение |
|
| Файл | Назначение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pump_controller_8_2_OLED_DONE.ino` | Логика прошивки |
|
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) |
|
||||||
| `web_page.h` | HTML веб-панели в PROGMEM (см. 7.7 — вынесен не для красоты) |
|
|
||||||
| `README.md` | Техническая документация (этот файл) |
|
| `README.md` | Техническая документация (этот файл) |
|
||||||
| `MANUAL.md` | Руководство пользователя / монтажника |
|
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||||||
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
||||||
| `docs/img/` | Снимки экрана для документации |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на п
|
|||||||
|
|
||||||
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
|
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
|
||||||
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
|
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
|
||||||
выполнился, иначе реле останется включённым на всё время старта.
|
выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания.
|
||||||
|
|
||||||
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -79,42 +77,12 @@ Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на п
|
|||||||
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
|
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Что происходит при перезагрузке на самом деле
|
|
||||||
|
|
||||||
Проверено на собранном приборе: **при перезагрузке оба реле кратковременно
|
|
||||||
моргают.** Признано некритичным и оставлено как есть.
|
|
||||||
|
|
||||||
Ранее в этом разделе утверждалось, что реле при загрузке молчат — это оказалось
|
|
||||||
неверно, и формулировка исправлена.
|
|
||||||
|
|
||||||
Наиболее вероятный механизм: оба выхода неизбежно сидят на служебных пинах.
|
|
||||||
`GPIO2` (`D4`) — это ещё и `UART1 TX`, и на нём во время загрузки появляется
|
|
||||||
служебный вывод; `GPIO0` (`D3`) — пин выбора режима загрузки, который дёргается
|
|
||||||
схемой сброса. Пока `setup()` не выполнился, уровнем на них управляет не
|
|
||||||
прошивка. Точную причину не измеряли: осциллографа в проверке не было.
|
|
||||||
|
|
||||||
Часть морганий может приходиться на светодиод релейного модуля, а не на
|
|
||||||
фактическое переключение контакта: короткий импульс успевает зажечь индикатор,
|
|
||||||
но не перебросить якорь. Различить можно тестером на контактах реле, если это
|
|
||||||
когда-нибудь станет важно.
|
|
||||||
|
|
||||||
**Почему решение всё равно верное.** Альтернатива — `D8` — не улучшила бы
|
|
||||||
ситуацию, а ухудшила: он подтянут к `LOW`, и реле было бы уверенно **включено
|
|
||||||
всю загрузку**, а не моргнуло бы. Кратковременное моргание предпочтительнее
|
|
||||||
устойчивого ложного сигнала на несколько секунд.
|
|
||||||
|
|
||||||
**Если моргание когда-нибудь станет проблемой** (например, сирена успевает
|
|
||||||
пискнуть): ставится RC-цепочка на вход релейного модуля, чтобы короткие импульсы
|
|
||||||
не проходили, либо модуль с триггером Шмитта на входе. Прошивку менять не
|
|
||||||
потребуется.
|
|
||||||
|
|
||||||
### 2.4 Прочие особенности ESP8266
|
### 2.4 Прочие особенности ESP8266
|
||||||
|
|
||||||
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
|
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
|
||||||
«Резерв» получает бесплатную индикацию на плате.
|
«Резерв» получает бесплатную индикацию на плате.
|
||||||
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
|
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
|
||||||
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
|
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
|
||||||
Кроме того, оба реле кратковременно моргают при любой перезагрузке (см. 2.3).
|
|
||||||
Косметика, но пугает.
|
Косметика, но пугает.
|
||||||
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
|
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
|
||||||
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
|
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
|
||||||
@@ -245,37 +213,18 @@ pE = стоит -> реле «Резерв» выключено
|
|||||||
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
|
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"event":"MAIN PUMPS","state":"ALARM",
|
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
|
||||||
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Поле | Назначение |
|
| `event` | `status` |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
|
| `MAIN PUMPS` | `ALARM` / `OK` |
|
||||||
| `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
|
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
|
||||||
| `status` | **Человеческая фраза**, та же что в панели |
|
|
||||||
| `device` | Имя прибора из настроек |
|
|
||||||
|
|
||||||
Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
|
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
|
||||||
момент отправки — это те же слова, что видит оператор в панели:
|
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
|
||||||
|
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
|
||||||
| Ситуация | `status` |
|
запроса ему нечем.
|
||||||
|---|---|
|
|
||||||
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
|
|
||||||
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
|
|
||||||
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
|
|
||||||
| Норма | `Подача есть, работает насос 1` |
|
|
||||||
|
|
||||||
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
|
|
||||||
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
|
|
||||||
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
|
|
||||||
|
|
||||||
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
|
|
||||||
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
|
|
||||||
разошлись бы при первой правке.
|
|
||||||
|
|
||||||
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
|
|
||||||
поэтому экранирование в JSON не требуется нигде.
|
|
||||||
|
|
||||||
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` —
|
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` —
|
||||||
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
||||||
@@ -442,105 +391,50 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
|
|||||||
|
|
||||||
## 7. Веб-интерфейс
|
## 7. Веб-интерфейс
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
*Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.*
|
|
||||||
|
|
||||||
Сервер на порту `80`.
|
Сервер на порту `80`.
|
||||||
|
|
||||||
| Маршрут | Метод | Описание |
|
| Маршрут | Метод | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/` | `GET` | Статическая HTML-панель из PROGMEM |
|
| `/` | `GET` | HTML-панель мониторинга |
|
||||||
| `/status` | `GET` | Живое состояние, JSON |
|
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` |
|
||||||
| `/config` | `GET` | Настройки и адрес в сети, JSON |
|
|
||||||
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
||||||
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
|
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
|
||||||
|
|
||||||
### 7.1 Почему страница статическая
|
### 7.1 `/set_config`
|
||||||
|
|
||||||
Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
|
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** —
|
||||||
**14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
|
так частично заполненная форма не обнуляет остальные параметры. Запись в файл
|
||||||
лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
|
выполняется только если что-то реально изменилось.
|
||||||
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
|
|
||||||
|
|
||||||
Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
|
| Параметр | Валидация | При отказе |
|
||||||
`location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
|
|---|---|---|
|
||||||
пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
|
| `url` | 1…220 символов | поле не меняется |
|
||||||
трогается.
|
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется |
|
||||||
|
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
|
||||||
|
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
|
||||||
|
|
||||||
Ответы:
|
Фильтрация `device` вместо экранирования выбрана осознанно: разрешённый набор
|
||||||
|
символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно
|
||||||
|
нигде.
|
||||||
|
|
||||||
```json
|
### 7.2 Состояние реле
|
||||||
/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 содержит
|
`rMain` и `rEmg` в `/status` — это **логическое** состояние реле из переменных
|
||||||
секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
|
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано
|
||||||
|
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
|
||||||
|
обратное действительности.
|
||||||
|
|
||||||
### 7.2 Логика статуса и цвета
|
### 7.3 Автообновление
|
||||||
|
|
||||||
Цвета взяты по конвенции сигнальных ламп, а не по вкусу:
|
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при любом
|
||||||
|
изменении JSON. Поскольку состояние реле теперь входит в ответ, страница
|
||||||
|
обновляется и при срабатывании реле. Шрифты подгружаются с Google Fonts — при
|
||||||
|
отсутствии интернета у клиента интерфейс отрисуется системным моноширинным
|
||||||
|
шрифтом.
|
||||||
|
|
||||||
| Состояние | Цвет | Заголовок | Условие |
|
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить webhook
|
||||||
|---|---|---|---|
|
и изменить выдержки. Контроллер рассчитан на изолированный технологический
|
||||||
| Запуск | серый | «Запуск» | `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, подменить адрес
|
|
||||||
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
|
|
||||||
Контроллер рассчитан на изолированный технологический сегмент.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -548,32 +442,28 @@ Arduino. Проверять сборку нужно через `arduino-cli` (р
|
|||||||
|
|
||||||
Обновление раз в секунду (`DISPLAY_INTERVAL`).
|
Обновление раз в секунду (`DISPLAY_INTERVAL`).
|
||||||
|
|
||||||
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
|
|
||||||
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
|
|
||||||
менялся и ничего не сообщал.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
NO SUPPLY <- кегль 2, до 10 символов
|
PUMP CONTROLLER v8
|
||||||
────────────────────────
|
────────────────────────
|
||||||
P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
|
PUMP 1 : RUNNING
|
||||||
ALARM:ON BACKUP:ON <- состояние выходов
|
PUMP 2 : STOPPED
|
||||||
|
BACKUP : STANDBY
|
||||||
────────────────────────
|
────────────────────────
|
||||||
192.168.1.128
|
192.168.1.42
|
||||||
|
STATUS: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
| Условие | Слово |
|
Строка статуса:
|
||||||
|
|
||||||
|
| Условие | Текст |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `!systemReady` | `STARTING` |
|
| `!systemReady` | `INIT...` |
|
||||||
| оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
|
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) |
|
||||||
| оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
|
| работает резерв | `WARN: BACKUP RUNNING` |
|
||||||
| работает резерв, основные в порядке | `BACKUP RUN` |
|
| иначе | `STATUS: OK` |
|
||||||
| иначе | `SUPPLY OK` |
|
|
||||||
|
|
||||||
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
|
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны
|
||||||
тащить её во флеш ради четырёх слов не стоит.
|
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`.
|
||||||
|
|
||||||
Состояние обоих реле теперь выводится — освободились две строки после
|
|
||||||
объединения датчиков в одну.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -627,29 +517,14 @@ ALARM:ON BACKUP:ON <- состояние выходов
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Сборка и прошивка
|
## 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
|
```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 .
|
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```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 .
|
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||||||
```
|
```
|
||||||
|
|
||||||
Монитор порта: **115200 бод**. Если заливка падает с
|
Монитор порта: 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.
|
Before Width: | Height: | Size: 52 KiB |
@@ -283,26 +283,23 @@ void settingsBegin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// OLED - макет экрана 128x64
|
// OLED — отображение состояния
|
||||||
// ============================================================
|
// ============================================================
|
||||||
/*
|
/*
|
||||||
Верхняя строка отдана главному ответу кеглем 2 (12x16 px) - читается от
|
Макет экрана 128x64 (8 строк по 8px при шрифте 1):
|
||||||
двери. Прежний постоянный заголовок "PUMP CONTROLLER v8" её занимал, но
|
|
||||||
ничего не сообщал. При аварии строка мигает инверсией раз в 500 мс.
|
|
||||||
|
|
||||||
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не
|
┌──────────────────────────┐
|
||||||
содержит, а тащить её во флеш ради четырёх слов не стоит.
|
│ PUMP CONTROLLER │ строка 0 — заголовок
|
||||||
|
│ ────────────────────── │ строка 1 — разделитель
|
||||||
|
│ НАСОС 1: РАБОТАЕТ │ строка 2
|
||||||
|
│ НАСОС 2: СТОИТ │ строка 3
|
||||||
|
│ РЕЗЕРВ : СТОИТ │ строка 4
|
||||||
|
│ ────────────────────── │ строка 5 — разделитель
|
||||||
|
│ WiFi: 192.168.1.42 │ строка 6
|
||||||
|
│ СТАТУС: НОРМА │ строка 7
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
+--------------------------+
|
При аварии строка 7 мигает "!! АВАРИЯ !!"
|
||||||
| 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -319,56 +316,61 @@ void setRelay(int pin, bool on) {
|
|||||||
void updateDisplay() {
|
void updateDisplay() {
|
||||||
if (!oledOK) return;
|
if (!oledOK) return;
|
||||||
|
|
||||||
bool alarm = (!p1.stableState && !p2.stableState);
|
|
||||||
|
|
||||||
display.clearDisplay();
|
display.clearDisplay();
|
||||||
display.setTextColor(SSD1306_WHITE);
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
// --- Главный ответ, кегль 2 (12x16 px, до 10 символов) ---
|
// --- Заголовок ---
|
||||||
// Верхняя строка отдана статусу: прежний заголовок "PUMP CONTROLLER v8"
|
display.setTextSize(1);
|
||||||
// занимал её постоянно, но никогда не менялся и ничего не сообщал.
|
display.setCursor(0, 0);
|
||||||
const __FlashStringHelper* word;
|
display.print(F(" PUMP CONTROLLER v8"));
|
||||||
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);
|
display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE);
|
||||||
if (blink) {
|
|
||||||
display.fillRect(0, 0, SCREEN_WIDTH, 18, SSD1306_WHITE);
|
// --- Состояния насосов ---
|
||||||
|
display.setCursor(0, 14);
|
||||||
|
display.print(F("PUMP 1 : "));
|
||||||
|
display.print(p1.stableState ? F("RUNNING") : F("STOPPED"));
|
||||||
|
|
||||||
|
display.setCursor(0, 24);
|
||||||
|
display.print(F("PUMP 2 : "));
|
||||||
|
display.print(p2.stableState ? F("RUNNING") : F("STOPPED"));
|
||||||
|
|
||||||
|
display.setCursor(0, 34);
|
||||||
|
display.print(F("BACKUP : "));
|
||||||
|
display.print(pE.stableState ? F("ACTIVE ") : F("STANDBY"));
|
||||||
|
|
||||||
|
// --- Разделитель ---
|
||||||
|
display.drawFastHLine(0, 44, SCREEN_WIDTH, SSD1306_WHITE);
|
||||||
|
|
||||||
|
// --- IP / статус WiFi ---
|
||||||
|
display.setCursor(0, 47);
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
display.print(WiFi.localIP().toString());
|
||||||
|
} else {
|
||||||
|
display.print(F("WiFi: NO CONNECTION"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Строка статуса ---
|
||||||
|
bool alarm = (!p1.stableState && !p2.stableState);
|
||||||
|
bool emergency = pE.stableState;
|
||||||
|
|
||||||
|
display.setCursor(0, 56);
|
||||||
|
if (!systemReady) {
|
||||||
|
display.print(F("INIT..."));
|
||||||
|
} else if (alarm) {
|
||||||
|
// Мигание каждые 500 мс
|
||||||
|
if ((millis() / 500) % 2 == 0) {
|
||||||
|
display.fillRect(0, 54, SCREEN_WIDTH, 10, SSD1306_WHITE);
|
||||||
display.setTextColor(SSD1306_BLACK);
|
display.setTextColor(SSD1306_BLACK);
|
||||||
}
|
}
|
||||||
display.setTextSize(2);
|
display.print(F("!! ALARM: NO PUMPS !!"));
|
||||||
display.setCursor(0, 1);
|
|
||||||
display.print(word);
|
|
||||||
display.setTextColor(SSD1306_WHITE);
|
display.setTextColor(SSD1306_WHITE);
|
||||||
display.setTextSize(1);
|
} else if (emergency) {
|
||||||
|
display.print(F("WARN: BACKUP RUNNING"));
|
||||||
display.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE);
|
} else {
|
||||||
|
display.print(F("STATUS: OK"));
|
||||||
// --- Датчики: три поля в одной строке вместо трёх строк ---
|
}
|
||||||
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();
|
display.display();
|
||||||
}
|
}
|
||||||
@@ -410,7 +412,7 @@ static bool parseWebhookHost(const char* url, String &host) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendPostWebhook(String eventName, String state) {
|
void sendPostWebhook(String eventName, String status) {
|
||||||
if (settings.webhookUrl[0] == '\0') {
|
if (settings.webhookUrl[0] == '\0') {
|
||||||
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
||||||
return;
|
return;
|
||||||
@@ -467,14 +469,9 @@ void sendPostWebhook(String eventName, String state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.addHeader("Content-Type", "application/json");
|
http.addHeader("Content-Type", "application/json");
|
||||||
// status — человеческая фраза, та же что в панели: «Насосы стоят, подачу
|
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
|
||||||
// держит резерв». state оставлен машиночитаемым (ALARM / OK / STARTED /
|
// в JSON не требуется — сломать тело запроса ему нечем.
|
||||||
// STOPPED), чтобы сценарий на сервере ветвился по нему, а не разбирал текст.
|
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status +
|
||||||
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], фразы кавычек
|
|
||||||
// и обратных слэшей не содержат — экранирование в JSON нигде не нужно.
|
|
||||||
String body = "{\"event\":\"" + eventName +
|
|
||||||
"\",\"state\":\"" + state +
|
|
||||||
"\",\"status\":\"" + statusPhrase() +
|
|
||||||
"\",\"device\":\"" + String(settings.deviceName) + "\"}";
|
"\",\"device\":\"" + String(settings.deviceName) + "\"}";
|
||||||
int code = http.POST(body);
|
int code = http.POST(body);
|
||||||
|
|
||||||
@@ -537,57 +534,191 @@ void updatePumpState(Pump &p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Словесное описание состояния
|
// HTML интерфейс (без изменений логики, только IP-адрес актуален)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Единственное место, где живёт формулировка. Её берут и веб-панель (через
|
String getHTML() {
|
||||||
// /status), и тело webhook — иначе два набора фраз разошлись бы при первой
|
bool isAlarm = (!p1.stableState && !p2.stableState);
|
||||||
// же правке. Фразы не содержат кавычек и обратных слэшей, поэтому безопасно
|
|
||||||
// вставляются в JSON без экранирования.
|
|
||||||
|
|
||||||
// Вид состояния: им панель выбирает цвет плашки.
|
String html = F("<!DOCTYPE html><html lang='ru'><head><meta charset='UTF-8'>");
|
||||||
static const char* statusKind() {
|
html += "<link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><circle cx=%2250%22 cy=%2250%22 r=%2245%22 fill=%22%23070b12%22 stroke=%22%232ea043%22 stroke-width=%228%22/><circle cx=%2250%22 cy=%2250%22 r=%2220%22 fill=%22none%22 stroke=%22%232ea043%22 stroke-width=%226%22/><line x1=%2250%22 y1=%225%22 x2=%2250%22 y2=%2222%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2250%22 y1=%2278%22 x2=%2250%22 y2=%2295%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%225%22 y1=%2250%22 x2=%2222%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2278%22 y1=%2250%22 x2=%2295%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/></svg>'>";
|
||||||
if (!systemReady) return "init";
|
html += F("<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
if (!p1.stableState && !p2.stableState) return "flt";
|
"<title>Мониторинг Насосов</title>"
|
||||||
if (pE.stableState) return "warn";
|
"<style>"
|
||||||
return "ok";
|
"@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Unbounded:wght@400;700&display=swap');"
|
||||||
|
"*{box-sizing:border-box;margin:0;padding:0}"
|
||||||
|
"body{font-family:'JetBrains Mono',monospace;background:#070b12;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}"
|
||||||
|
".dash{background:#070b12;border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:28px 24px;width:100%;max-width:520px;position:relative;overflow:hidden}"
|
||||||
|
".dash::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 80% 20%,rgba(20,80,160,0.18) 0%,transparent 70%),radial-gradient(ellipse 40% 30% at 10% 80%,rgba(10,120,80,0.12) 0%,transparent 60%);pointer-events:none}"
|
||||||
|
".grid-bg{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.025) 1px,transparent 1px);background-size:40px 40px;pointer-events:none}"
|
||||||
|
".header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px;position:relative;z-index:1}"
|
||||||
|
".sys-label{font-family:'Unbounded',monospace;font-size:9px;letter-spacing:0.2em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:4px}"
|
||||||
|
".title{font-family:'Unbounded',monospace;font-size:17px;font-weight:700;color:#e8edf5;letter-spacing:0.02em}"
|
||||||
|
".refresh-badge{display:flex;align-items:center;gap:6px;font-size:10px;color:rgba(255,255,255,0.3);letter-spacing:0.08em;margin-top:6px}"
|
||||||
|
".pulse-dot{width:6px;height:6px;border-radius:50%;background:#2ea043;animation:livePulse 2s ease-in-out infinite}"
|
||||||
|
"@keyframes livePulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}}"
|
||||||
|
".time-block{text-align:right}.time-val{font-size:20px;font-weight:700;color:rgba(255,255,255,0.55);letter-spacing:0.05em}.date-val{font-size:10px;color:rgba(255,255,255,0.2);letter-spacing:0.1em;margin-top:2px}"
|
||||||
|
".pumps-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;position:relative;z-index:1}"
|
||||||
|
".pump-card{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:14px 12px;position:relative;overflow:hidden}"
|
||||||
|
".pump-card.active{border-color:rgba(46,160,67,0.4);background:rgba(46,160,67,0.05)}"
|
||||||
|
".pump-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,#2ea043,transparent);animation:scanLine 3s linear infinite}"
|
||||||
|
".pump-card.emergency.active{border-color:rgba(218,54,51,0.5);background:rgba(218,54,51,0.06)}"
|
||||||
|
".pump-card.emergency.active::after{background:linear-gradient(90deg,transparent,#da3633,transparent)}"
|
||||||
|
"@keyframes scanLine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}"
|
||||||
|
".pump-type{font-size:8px;letter-spacing:0.18em;color:rgba(255,255,255,0.22);text-transform:uppercase;margin-bottom:8px}.pump-type.emerg{color:rgba(218,54,51,0.45)}"
|
||||||
|
".pump-icon{width:32px;height:32px;margin-bottom:8px}.pump-name{font-size:11px;font-weight:500;color:#c9d1d9;margin-bottom:10px;line-height:1.4}"
|
||||||
|
".pump-status{display:flex;align-items:center;gap:6px}.led{width:7px;height:7px;border-radius:50%;flex-shrink:0}"
|
||||||
|
".led.on{background:#2ea043;box-shadow:0 0 6px rgba(46,160,67,0.8);animation:ledGlow 2s ease-in-out infinite}.led.on.emerg{background:#da3633;box-shadow:0 0 6px rgba(218,54,51,0.8)}.led.off{background:rgba(255,255,255,0.12)}"
|
||||||
|
"@keyframes ledGlow{0%,100%{opacity:1}50%{opacity:0.55}}.stext{font-size:9px;letter-spacing:0.1em;text-transform:uppercase;font-weight:500}"
|
||||||
|
".stext.on{color:#2ea043}.stext.on.emerg{color:#da3633}.stext.off{color:rgba(255,255,255,0.2)}"
|
||||||
|
".meter{margin-top:10px;height:3px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}.mfill{height:100%;border-radius:2px;background:#2ea043;transition:width 1s ease}.mfill.emerg{background:#da3633}"
|
||||||
|
".bottom-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;position:relative;z-index:1}"
|
||||||
|
".spanel{border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}.spanel.ok{background:rgba(35,134,54,0.12);border:1px solid rgba(46,160,67,0.3)}"
|
||||||
|
".spanel.crit{background:rgba(248,81,73,0.08);border:1px solid rgba(248,81,73,0.3)}"
|
||||||
|
".sicon{width:26px;height:26px;flex-shrink:0}"
|
||||||
|
".scode{font-size:8px;letter-spacing:0.15em;text-transform:uppercase;margin-bottom:3px}.scode.ok{color:#2ea043}.scode.crit{color:#f85149}"
|
||||||
|
".smsg{font-size:11px;font-weight:500}.smsg.ok{color:#c9d1d9}.smsg.crit{color:#f85149}"
|
||||||
|
".cpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||||
|
".clabel{font-size:8px;letter-spacing:0.15em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:8px}"
|
||||||
|
".cbtn{width:100%;padding:7px 0;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
|
||||||
|
".cbtn:hover{background:rgba(255,255,255,0.1)}"
|
||||||
|
".wh-row{margin-top:10px;position:relative;z-index:1}"
|
||||||
|
".wh-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||||
|
".wh-url{font-size:9px;color:rgba(255,255,255,0.35);word-break:break-all;margin-bottom:8px}"
|
||||||
|
".wh-input{width:100%;padding:6px 8px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px}"
|
||||||
|
".cbtn-save{width:100%;padding:7px 0;background:rgba(46,160,67,0.15);border:1px solid rgba(46,160,67,0.3);border-radius:6px;color:#2ea043;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
|
||||||
|
".emg-row{margin-top:10px;position:relative;z-index:1}"
|
||||||
|
".emg-panel{background:rgba(248,81,73,0.06);border:1px solid rgba(248,81,73,0.25);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}"
|
||||||
|
".relay-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px;position:relative;z-index:1}"
|
||||||
|
".rpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||||
|
".rpanel.on{border-color:rgba(46,160,67,0.35);background:rgba(46,160,67,0.06)}"
|
||||||
|
".rpanel.on.emerg{border-color:rgba(218,54,51,0.4);background:rgba(218,54,51,0.06)}"
|
||||||
|
".rstate{display:flex;align-items:center;gap:6px;margin-top:6px}"
|
||||||
|
".cfg-row{margin-top:10px;position:relative;z-index:1}"
|
||||||
|
".cfg-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||||
|
".cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}"
|
||||||
|
".cfg-f{display:flex;flex-direction:column;gap:4px}"
|
||||||
|
".cfg-l{font-size:9px;letter-spacing:0.08em;color:rgba(255,255,255,0.3);text-transform:uppercase}"
|
||||||
|
".cfg-wide{grid-column:1 / -1}"
|
||||||
|
"</style></head><body><div class='dash'><div class='grid-bg'></div>");
|
||||||
|
|
||||||
|
html += F("<div class='header'>"
|
||||||
|
"<div><div class='sys-label'>SYS · PUMP_CTRL</div>"
|
||||||
|
"<div class='title'>Мониторинг<br>Насосов</div>"
|
||||||
|
"<div class='refresh-badge'><div class='pulse-dot'></div>LIVE · AUTO</div></div>"
|
||||||
|
"<div class='time-block'><div class='time-val' id='clk'>--:--:--</div><div class='date-val' id='dt'></div></div>"
|
||||||
|
"</div>");
|
||||||
|
|
||||||
|
html += "<div class='pumps-grid'>";
|
||||||
|
|
||||||
|
// Насос 1
|
||||||
|
html += "<div class='pump-card" + String(p1.stableState ? " active" : "") + "'>";
|
||||||
|
html += F("<div class='pump-type'>Насос</div>"
|
||||||
|
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||||
|
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"</svg>");
|
||||||
|
html += "<div class='pump-name'>Основной 1</div><div class='pump-status'><div class='led " + String(p1.stableState ? "on" : "off") + "'></div><div class='stext " + String(p1.stableState ? "on" : "off") + "'>" + String(p1.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p1.stableState ? "80" : "0") + "%'></div></div></div>";
|
||||||
|
|
||||||
|
// Насос 2
|
||||||
|
html += "<div class='pump-card" + String(p2.stableState ? " active" : "") + "'>";
|
||||||
|
html += F("<div class='pump-type'>Насос</div>"
|
||||||
|
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||||
|
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"</svg>");
|
||||||
|
html += "<div class='pump-name'>Основной 2</div><div class='pump-status'><div class='led " + String(p2.stableState ? "on" : "off") + "'></div><div class='stext " + String(p2.stableState ? "on" : "off") + "'>" + String(p2.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p2.stableState ? "80" : "0") + "%'></div></div></div>";
|
||||||
|
|
||||||
|
// Резерв
|
||||||
|
html += "<div class='pump-card emergency" + String(pE.stableState ? " active" : "") + "'>";
|
||||||
|
html += F("<div class='pump-type emerg'>Резерв</div>"
|
||||||
|
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||||
|
"<path d='M16 3 L28 27 L4 27 Z' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||||
|
"<line x1='16' y1='12' x2='16' y2='20' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||||
|
"<circle cx='16' cy='23' r='1.5' fill='currentColor'/>"
|
||||||
|
"</svg>");
|
||||||
|
html += "<div class='pump-name'>Резерв</div><div class='pump-status'><div class='led " + String(pE.stableState ? "on emerg" : "off") + "'></div><div class='stext " + String(pE.stableState ? "on emerg" : "off") + "'>" + String(pE.stableState ? "Активен" : "Ожидание") + "</div></div><div class='meter'><div class='mfill" + String(pE.stableState ? " emerg" : "") + "' style='width:" + String(pE.stableState ? "55" : "0") + "%'></div></div></div>";
|
||||||
|
|
||||||
|
html += "</div><div class='bottom-row'>";
|
||||||
|
|
||||||
|
if (isAlarm) {
|
||||||
|
html += "<div class='spanel crit'><svg class='sicon' viewBox='0 0 26 26' fill='none'><path d='M13 3 L23 21 L3 21 Z' stroke='#f85149' stroke-width='1.5' fill='rgba(248,81,73,0.1)'/><line x1='13' y1='10' x2='13' y2='16' stroke='#f85149' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode crit'>CRITICAL · 0xFF</div><div class='smsg crit'>Нет подачи!</div></div></div>";
|
||||||
|
} else {
|
||||||
|
html += "<div class='spanel ok'><svg class='sicon' viewBox='0 0 26 26' fill='none'><circle cx='13' cy='13' r='10' stroke='#2ea043' stroke-width='1.5'/><path d='M8 13 L11.5 16.5 L18 10' stroke='#2ea043' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode ok'>SYS_OK · 0x00</div><div class='smsg ok'>Система: норма</div></div></div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Главное слово — крупная строка в панели и начало фразы в уведомлении.
|
html += "<div class='cpanel'><div class='clabel'>Управление</div><form action='/reset_wifi' method='POST' style='margin:0'><button type='submit' class='cbtn'>Сбросить Wi-Fi</button></form></div></div>";
|
||||||
static const __FlashStringHelper* statusWord() {
|
// --- Состояние реле ---
|
||||||
if (!systemReady) return F("Запуск");
|
html += "<div class='relay-row'>";
|
||||||
if (!p1.stableState && !p2.stableState) return F("Насосы стоят");
|
html += "<div class='rpanel" + String(relayMainOn ? " on" : "") + "'>";
|
||||||
if (pE.stableState) return F("Работает резерв");
|
html += F("<div class='clabel'>Реле «Авария»</div>");
|
||||||
return F("Подача есть");
|
html += "<div class='rstate'><div class='led " + String(relayMainOn ? "on" : "off") + "'></div>";
|
||||||
|
html += "<div class='stext " + String(relayMainOn ? "on" : "off") + "'>" +
|
||||||
|
String(relayMainOn ? "Включено" : "Выключено") + "</div></div></div>";
|
||||||
|
|
||||||
|
html += "<div class='rpanel emerg" + String(relayEmergOn ? " on" : "") + "'>";
|
||||||
|
html += F("<div class='clabel'>Реле «Резерв»</div>");
|
||||||
|
html += "<div class='rstate'><div class='led " + String(relayEmergOn ? "on emerg" : "off") + "'></div>";
|
||||||
|
html += "<div class='stext " + String(relayEmergOn ? "on emerg" : "off") + "'>" +
|
||||||
|
String(relayEmergOn ? "Включено" : "Выключено") + "</div></div></div>";
|
||||||
|
html += "</div>";
|
||||||
|
|
||||||
|
// --- Настройки ---
|
||||||
|
html += F("<div class='cfg-row'><div class='cfg-panel'><div class='clabel'>Настройки</div>");
|
||||||
|
if (!settingsFsReady) {
|
||||||
|
html += F("<div class='smsg crit' style='margin-bottom:8px'>"
|
||||||
|
"Файловая система недоступна — настройки не переживут перезагрузку!</div>");
|
||||||
|
}
|
||||||
|
html += "<div class='wh-url'>Webhook: " +
|
||||||
|
String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "</div>";
|
||||||
|
html += F("<form action='/set_config' method='POST' style='margin:0'><div class='cfg-grid'>");
|
||||||
|
html += F("<div class='cfg-f cfg-wide'><div class='cfg-l'>Webhook URL</div>"
|
||||||
|
"<input class='wh-input' type='text' name='url' placeholder='https://... (пусто = не менять)' maxlength='220'></div>");
|
||||||
|
html += "<div class='cfg-f cfg-wide'><div class='cfg-l'>Имя устройства (A-Z a-z 0-9 _ -)</div>"
|
||||||
|
"<input class='wh-input' type='text' name='device' value='" + String(settings.deviceName) +
|
||||||
|
"' maxlength='31'></div>";
|
||||||
|
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка аварии, с</div>"
|
||||||
|
"<input class='wh-input' type='number' name='alarm_delay' min='0' max='3600' value='" +
|
||||||
|
String(settings.alarmDelaySec) + "'></div>";
|
||||||
|
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка резерва, с</div>"
|
||||||
|
"<input class='wh-input' type='number' name='emerg_delay' min='0' max='3600' value='" +
|
||||||
|
String(settings.emergDelaySec) + "'></div>";
|
||||||
|
html += F("</div><button type='submit' class='cbtn-save' style='margin-top:8px'>Сохранить</button>"
|
||||||
|
"</form></div></div>");
|
||||||
|
|
||||||
|
if (pE.stableState) {
|
||||||
|
html += "<div class='emg-row'><div class='emg-panel'><svg class='sicon' viewBox='0 0 26 26' fill='none'><path d='M13 3 L23 21 L3 21 Z' stroke='#f85149' stroke-width='1.5' fill='rgba(248,81,73,0.1)'/><circle cx='13' cy='19' r='1' fill='#f85149'/></svg><div><div class='scode crit'>WARN · BACKUP</div><div class='smsg crit'>Работает резерв!</div></div></div></div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Уточнение — вторая строка в панели и хвост фразы в уведомлении.
|
html += F("<script>"
|
||||||
static const __FlashStringHelper* statusSub() {
|
"let lastS = '';"
|
||||||
if (!systemReady) return F("идёт проверка датчиков");
|
"function check(){"
|
||||||
if (!p1.stableState && !p2.stableState) {
|
"fetch('/status').then(r=>r.json()).then(data=>{"
|
||||||
return pE.stableState ? F("подачу держит резерв") : F("подачи нет");
|
"let s = JSON.stringify(data);"
|
||||||
}
|
"if(lastS !== '' && lastS !== s) location.reload();"
|
||||||
if (pE.stableState) return F("основные насосы в работе");
|
"lastS = s;"
|
||||||
if (p1.stableState && p2.stableState) return F("работают оба насоса");
|
"}).catch(()=>{});"
|
||||||
return p1.stableState ? F("работает насос 1") : F("работает насос 2");
|
"}"
|
||||||
}
|
"function tick(){"
|
||||||
|
"var n=new Date();"
|
||||||
|
"var h=('0'+n.getHours()).slice(-2), m=('0'+n.getMinutes()).slice(-2), s=('0'+n.getSeconds()).slice(-2);"
|
||||||
|
"document.getElementById('clk').textContent=h+':'+m+':'+s;"
|
||||||
|
"var mo=['ЯНВ','ФЕВ','МАР','АПР','МАЙ','ИЮН','ИЮЛ','АВГ','СЕН','ОКТ','НОЯ','ДЕК'];"
|
||||||
|
"document.getElementById('dt').textContent=n.getDate()+' '+mo[n.getMonth()]+' '+n.getFullYear();"
|
||||||
|
"}"
|
||||||
|
"tick(); setInterval(tick,1000); setInterval(check,2000);"
|
||||||
|
"</script></div></body></html>");
|
||||||
|
|
||||||
// Готовая фраза целиком: «Насосы стоят, подачу держит резерв».
|
return html;
|
||||||
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
|
// SETUP
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -662,44 +793,16 @@ void setup() {
|
|||||||
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
|
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
|
||||||
|
|
||||||
// Web-сервер
|
// Web-сервер
|
||||||
// Страница отдаётся прямо из флеша, минуя кучу.
|
|
||||||
server.on("/", HTTP_GET, []() {
|
server.on("/", HTTP_GET, []() {
|
||||||
server.send_P(200, PSTR("text/html"), PAGE_HTML);
|
server.send(200, "text/html", getHTML());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Живое состояние. Опрашивается раз в 2 секунды, поэтому держим ответ коротким.
|
|
||||||
server.on("/status", HTTP_GET, []() {
|
server.on("/status", HTTP_GET, []() {
|
||||||
String json = "{";
|
String json = "{";
|
||||||
json += "\"p1\":" + String(p1.stableState ? "1" : "0") + ",";
|
json += "\"p1\":" + String(p1.stableState ? "1" : "0") + ",";
|
||||||
json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ",";
|
json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ",";
|
||||||
json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ",";
|
json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ",";
|
||||||
json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ",";
|
json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ",";
|
||||||
json += "\"rEmg\":" + String(relayEmergOn ? "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 += "}";
|
json += "}";
|
||||||
server.send(200, "application/json", json);
|
server.send(200, "application/json", json);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
#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