Compare commits

...
8 Commits
Author SHA1 Message Date
oskarvitalii ac8a72b05d Merge dev into master: снимок веб-панели в документации 2026-08-08 09:32:58 +07:00
oskarvitaliiandClaude Opus 5 ed4dad0a52 docs: добавить снимок веб-панели
Снимок с работающего прибора: плашка «Подача есть», лампы датчиков и
выходов, раскрытый блок настроек. Вставлен в README (раздел 7) и в
MANUAL (раздел 5).

На снимке нет чувствительных данных: адрес уведомлений показан только
именем хоста и это заглушка, IP приватный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:30:25 +07:00
oskarvitalii 7a7075f6de Merge dev into master: исправлено описание поведения реле при загрузке
Документация утверждала, что реле при загрузке молчат. На собранном
приборе оба реле кратковременно моргают при перезагрузке; поведение
признано некритичным, прошивка не менялась, исправлен текст.
2026-08-08 01:55:23 +07:00
oskarvitaliiandClaude Opus 5 fde9ee799a docs: исправить утверждение о поведении реле при загрузке
На собранном приборе оба реле кратковременно моргают при перезагрузке.
README и MANUAL утверждали обратное - что реле при загрузке молчат.
Это было предсказание из рассуждения об уровнях на пинах, а не
наблюдение, и оно не подтвердилось. Формулировки исправлены.

Вероятный механизм описан честно, как вероятный: оба выхода сидят на
служебных пинах (GPIO2 это UART1 TX, GPIO0 - пин выбора режима загрузки),
и пока setup() не выполнился, уровнем управляет не прошивка. Осциллографом
не проверяли, о чём в тексте сказано.

Само решение остаётся верным: альтернативный D8 подтянут к LOW и держал бы
реле уверенно включённым всю загрузку вместо короткого моргания.

Пользователь признал поведение некритичным, прошивка не менялась. В
документации указано, чем лечится, если однажды станет мешать.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:51:30 +07:00
oskarvitalii f77ff376d4 Merge dev into master: переработанный интерфейс и текст события в webhook
Веб-панель переделана в световое табло: главный ответ читается с
расстояния, цвета по конвенции сигнальных ламп, настройки свёрнуты.
Исправлено: лампа выхода «Авария» горела зелёным при срабатывании,
обновление стирало ввод в форме, шрифты тянулись с Google Fonts и на
объекте не загружались.

Страница переехала во флеш (PROGMEM) и в отдельный файл web_page.h -
последнее вынужденно: генератор прототипов Arduino не понимает сырые
строковые литералы и ломался на JavaScript внутри.

OLED: верхняя строка отдана статусу кеглем 2, добавлено состояние реле.

В теле webhook поле status теперь содержит человеческую фразу
(«Насосы стоят, подачу держит резерв»), машиночитаемое значение
переехало в новое поле state.

Прошито и проверено на приборе: COM7, LOLIN D1 ESP-WROOM-02, 2MB FS:64KB.
2026-08-08 01:40:10 +07:00
oskarvitaliiandClaude Opus 5 0c06ba1032 feat(webhook): слать в status человеческую фразу вместо кода
В теле уведомления status теперь содержит ту же формулировку, что видит
оператор в панели: «Насосы стоят, подачу держит резерв». Прежние
машиночитаемые значения переехали в новое поле state (ALARM / OK /
STARTED / STOPPED), чтобы сценарий на сервере ветвился по нему, а не
разбирал русский текст.

ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ: если сценарий в n8n сравнивал status с "ALARM",
условие надо переключить на state.

Формулировки собраны в одном месте прошивки (statusKind / statusWord /
statusSub / statusPhrase) и оттуда попадают и в webhook, и в панель через
новые поля k, w, s в /status. Раньше та же логика была продублирована в
JS панели; два набора фраз разошлись бы при первой правке. Побочно JS
сократился почти вдвое.

Прошито и проверено на приборе: /status отдаёт
{"k":"ok","w":"Подача есть","s":"работает насос 1"}, /config по-прежнему
отдаёт только имя хоста без токена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:33:48 +07:00
oskarvitaliiandClaude Opus 5 6240862c27 fix(build): вынести HTML в web_page.h - сборка через arduino-cli падала
Генератор прототипов Arduino не понимает сырые строковые литералы: внутри
JS он нашёл "function draw(d){" и "function poll(){", принял их за
определения функций C++ и вставил для них прототипы в начало файла.
Сборка падала с "error: 'function' does not name a type", а директивы
#line показывали ошибку на строках внутри HTML.

Заголовочные файлы препроцессор не сканирует, поэтому страница переехала
в web_page.h. Тот же приём используется в соседнем проекте.

Ошибка не всплывала раньше потому, что я проверял скетч через
g++ -fsyntax-only по .ino: так файл компилируется как обычный .cpp и
препроцессор Arduino не участвует. Проверять сборку нужно arduino-cli,
это записано в README 11.1.

Прошито и проверено на железе (COM7, LOLIN D1 ESP-WROOM-02, 2MB FS:64KB):
размеры флеша совпадают, LittleFS монтируется, ранее заданные через панель
настройки пережили перепрошивку, Wi-Fi поднимается, датчики читаются.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:08:17 +07:00
oskarvitaliiandClaude Opus 5 cb151d2e30 feat(ui): переработать веб-панель и макет OLED
Панель как световое табло, а не дашборд: главный ответ занимает верх
страницы и читается с расстояния. Прежде самым крупным элементом были
часы браузера - единственное, что не несло информации о приборе, - а
ответ "есть подача или нет" был набран 11 кеглем в панели размером с
кнопку. Норма и авария выглядели почти одинаково.

Исправлены три содержательные ошибки:

- лампа выхода "Авария" горела ЗЕЛЁНЫМ при срабатывании: стиль
  "включено" был общим для всех ламп, и сработавшая сигнализация
  подсвечивалась цветом "всё хорошо";
- страница делала location.reload() при любом изменении состояния,
  стирая недописанный адрес в форме вместе с прокруткой и фокусом;
- CSS подключал шрифты с Google Fonts, тогда как сеть на объекте не
  разрешает внешние имена - нарисованный макет там не появлялся никогда.

Страница стала статической и уехала во флеш (PAGE_HTML PROGMEM, ~8 КБ,
отдаётся через send_P). Раньше HTML собирался строкой в куче на каждый
запрос: ~14 КБ при ~40 КБ свободной кучи. Состояние подставляет JS,
забирая /status и новый /config. В /status добавлено поле rdy.

/config отдаёт только имя хоста webhook: полный URL содержит секретный
токен, и выводить его на экран, видимый всем в помещении, незачем.

Цвета по конвенции сигнальных ламп: резерв теперь ЖЁЛТЫЙ, а не красный -
работающий резерв это предупреждение, система ещё справляется. Красный
оставлен для остановки основных. Убраны декоративные коды SYS_OK / 0xFF,
не кодировавшие ничего, и тройное дублирование состояния резерва.
Настройки свёрнуты в details.

OLED: верхняя строка отдана статусу кеглем 2 - прежде её постоянно
занимал заголовок "PUMP CONTROLLER v8", который никогда не менялся.
Три строки датчиков сжаты в одну, освободившееся место отдано состоянию
обоих реле. Текст остаётся латиницей: встроенный шрифт Adafruit GFX
кириллицы не содержит.

Проверено: сборка -Wall -Wextra без предупреждений; все три состояния
панели просмотрены в браузере на 375 и 800 px. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:43:34 +07:00
5 changed files with 574 additions and 364 deletions
+102 -60
View File
@@ -60,12 +60,18 @@ GND). **Сирену, лампу или пускатель напрямую к `
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
> HIGH во время загрузки платы, поэтому реле молчат при включении питания.
> HIGH во время загрузки, поэтому реле не остаются включёнными на всё время
> старта.
Оговорки, которые лучше знать заранее:
* Реле «Авария» **щёлкает при каждой заливке прошивки**схема автосброса USB
дёргает `D3`. Это нормально.
* **Оба реле кратковременно моргают при каждой перезагрузке прибора** — и при
подаче питания, и при заливке прошивки. Так и есть, это нормально: выходы
сидят на служебных пинах платы, и пока прошивка не запустилась, уровнем на них
управляет не она. Автоматика в этот момент ещё не работает, ложных уведомлений
моргание не вызывает.
* Если моргание окажется недопустимым (например, сирена успевает пискнуть) —
ставится RC-цепочка на вход релейного модуля. Прошивку менять не нужно.
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
проверки монтажа.
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
@@ -106,27 +112,33 @@ OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
## 4. Экран
```
PUMP CONTROLLER v8
SUPPLY OK
────────────────────────
PUMP 1 : RUNNING
PUMP 2 : STOPPED
BACKUP : STANDBY
P1:RUN P2:OFF BK:OFF
ALARM:OFF BACKUP:OFF
────────────────────────
192.168.1.42
STATUS: OK
192.168.1.128
```
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
| Надпись | Значение |
|---|---|
| `PUMP 1/2 : RUNNING` | Насос работает |
| `PUMP 1/2 : STOPPED` | Насос остановлен |
| `BACKUP : ACTIVE` | Резервный насос запущен |
| `BACKUP : STANDBY` | Резерв в ожидании |
| `WiFi: NO CONNECTION` | Нет связи с сетью |
| `INIT...` | Идёт запуск (первые ~4 секунды) |
| `STATUS: OK` | Норма |
| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание |
| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** |
| `SUPPLY OK` | Норма, подача идёт |
| `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
| `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
| `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
| `STARTING` | Идёт запуск, первые ~4 секунды |
Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
основные, `BK` — резерв.
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
Нижняя строка — адрес в сети или `no network`, если связи нет.
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
---
@@ -134,57 +146,81 @@ STATUS: OK
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
На странице:
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
лампы датчиков и выходов, в самом низу свёрнутые настройки.
* три карточки насосов с индикаторами;
* панель состояния системы (`SYS_OK` или `CRITICAL`);
* **состояние обоих реле** — «Включено» / «Выключено»;
* кнопка **Сбросить Wi-Fi**;
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
![Вид панели: состояние «Подача есть», лампы насосов и выходов, раскрытые настройки](docs/img/panel.jpg)
Страница обновляется автоматически при изменении состояния насосов или реле.
Так панель выглядит в норме. Здесь блок настроек раскрыт; обычно он свёрнут
в одну строку и не отвлекает.
### 5.1 Состояние реле
### 5.1 Главная плашка
Два индикатора показывают, что прибор реально подал на выход:
| Плашка | Цвет | Что значит |
|---|---|---|
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
| **Запуск** | серый | Первые ~4 секунды после включения |
| Индикатор | Значение |
|---|---|
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
Под заголовком — уточнение: какой именно насос работает, или «подачу держит
резерв», если основные стоят, а резерв качает.
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке.
Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
### 5.2 Настройки
**Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно
поправить одну выдержку, не трогая остальное. Неверное значение так же
игнорируется, поэтому опечатка ничего не испортит.
### 5.2 Лампы
Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
или «ожидание».
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
«разомкнут». Это то, что прибор реально подал на реле.
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
проблема в монтаже или в типе релейного модуля, а не в прошивке.
### 5.3 Настройки
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
и не отвлекают.
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
испортит.
| Поле | Что задаёт | Допустимо | По умолчанию |
|---|---|---|---|
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан |
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
| **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
| **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
| **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
| **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
секретный ключ, и выводить его на экран, который видно всем в помещении, не
нужно. Чтобы сменить адрес — введите новый целиком.
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
(см. раздел 7).
Настройки сохраняются в файл во внутренней памяти прибора, переживают
отключение питания и сброс Wi-Fi.
отключение питания и смену сети Wi-Fi.
> Если панель показывает красное предупреждение «Файловая система недоступна»
> или настройки пропадают после перезагрузки — причина в настройке `Flash Size`
> Если панель показывает красное предупреждение «Память недоступна» или
> настройки пропадают после перезагрузки — причина в настройке `Flash Size`
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
> 2 МБ приводит ровно к такому симптому.
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
> Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
**На новом приборе адрес уведомлений не задан** — панель покажет
`не задан - уведомления отключены`, сообщения отправляться не будут, всё
остальное работает.
**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
уведомления отключены», сообщения отправляться не будут, всё остальное работает.
---
@@ -192,12 +228,18 @@ STATUS: OK
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
| Событие | Что означает |
В сообщении приходит **та же фраза, что показывает панель** — читать можно
без расшифровки кодов:
| Ситуация | Текст в сообщении |
|---|---|
| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят |
| `MAIN PUMPS` / `OK` | Работа восстановлена |
| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос |
| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился |
| Оба основных стоят, резерв качает | «Насосы стоят, подачу держит резерв» |
| Оба основных стоят, резерва нет | «Насосы стоят, подачи нет» |
| Работает резерв, основные в порядке | «Работает резерв, основные насосы в работе» |
| Работа восстановлена | «Подача есть, работает насос 1» |
Рядом с текстом передаётся служебное поле для автоматики: `MAIN PUMPS` или
`EMERGENCY PUMP` и `ALARM` / `OK` / `STARTED` / `STOPPED`.
Уведомления отправляются один раз на каждое изменение, а не повторяются.
Если в момент события связи не было — уведомление **не будет** отправлено
@@ -219,7 +261,7 @@ STATUS: OK
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку
**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
доступа.
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
@@ -238,15 +280,15 @@ STATUS: OK
| Событие | Задержка |
|---|---|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с |
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с |
| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на
Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
реле и на уведомление** — отдельно задержать только уведомление нельзя.
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
антидребезга.
---
@@ -264,9 +306,9 @@ STATUS: OK
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет |
| Реле моргают при перезагрузке или заливке прошивки | Нормально, см. 2.3. Выходы на служебных пинах платы; пока прошивка не стартовала, уровнем управляет не она. Уведомления при этом не отправляются |
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
+183 -58
View File
@@ -12,10 +12,12 @@
| Файл | Назначение |
|---|---|
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) |
| `pump_controller_8_2_OLED_DONE.ino` | Логика прошивки |
| `web_page.h` | HTML веб-панели в PROGMEM (см. 7.7 — вынесен не для красоты) |
| `README.md` | Техническая документация (этот файл) |
| `MANUAL.md` | Руководство пользователя / монтажника |
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
| `docs/img/` | Снимки экрана для документации |
---
@@ -53,7 +55,7 @@ Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на п
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания.
выполнился, иначе реле останется включённым на всё время старта.
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|---|---|---|---|
@@ -77,12 +79,42 @@ Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на п
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 В, без внутренней подтяжки). Ни
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
@@ -213,18 +245,37 @@ pE = стоит -> реле «Резерв» выключено
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
```json
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
{"event":"MAIN PUMPS","state":"ALARM",
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
```
| `event` | `status` |
| Поле | Назначение |
|---|---|
| `MAIN PUMPS` | `ALARM` / `OK` |
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
| `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
| `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
| `status` | **Человеческая фраза**, та же что в панели |
| `device` | Имя прибора из настроек |
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
запроса ему нечем.
Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
момент отправки — это те же слова, что видит оператор в панели:
| Ситуация | `status` |
|---|---|
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
| Норма | `Подача есть, работает насос 1` |
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
разошлись бы при первой правке.
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
поэтому экранирование в JSON не требуется нигде.
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()`
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
@@ -391,50 +442,105 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
## 7. Веб-интерфейс
![Веб-панель контроллера: плашка состояния, лампы датчиков и выходов, раскрытые настройки](docs/img/panel.jpg)
*Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.*
Сервер на порту `80`.
| Маршрут | Метод | Описание |
|---|---|---|
| `/` | `GET` | HTML-панель мониторинга |
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` |
| `/` | `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 `/set_config`
### 7.1 Почему страница статическая
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»**
так частично заполненная форма не обнуляет остальные параметры. Запись в файл
выполняется только если что-то реально изменилось.
Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
**14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
| Параметр | Валидация | При отказе |
|---|---|---|
| `url` | 1…220 символов | поле не меняется |
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется |
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
`location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
трогается.
Фильтрация `device` вместо экранирования выбрана осознанно: разрешённый набор
символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно
нигде.
Ответы:
### 7.2 Состояние реле
```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"}
```
`rMain` и `rEmg` в `/status` — это **логическое** состояние реле из переменных
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
обратное действительности.
`/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
### 7.3 Автообновление
### 7.2 Логика статуса и цвета
Панель опрашивает `/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, подменить адрес
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
Контроллер рассчитан на изолированный технологический сегмент.
---
@@ -442,28 +548,32 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
Обновление раз в секунду (`DISPLAY_INTERVAL`).
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
менялся и ничего не сообщал.
```
PUMP CONTROLLER v8
NO SUPPLY <- кегль 2, до 10 символов
────────────────────────
PUMP 1 : RUNNING
PUMP 2 : STOPPED
BACKUP : STANDBY
P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
ALARM:ON BACKUP:ON <- состояние выходов
────────────────────────
192.168.1.42
STATUS: OK
192.168.1.128
```
Строка статуса:
| Условие | Текст |
| Условие | Слово |
|---|---|
| `!systemReady` | `INIT...` |
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) |
| работает резерв | `WARN: BACKUP RUNNING` |
| иначе | `STATUS: OK` |
| `!systemReady` | `STARTING` |
| оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
| оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
| работает резерв, основные в порядке | `BACKUP RUN` |
| иначе | `SUPPLY OK` |
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`.
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
тащить её во флеш ради четырёх слов не стоит.
Состояние обоих реле теперь выводится — освободились две строки после
объединения датчиков в одну.
---
@@ -517,14 +627,29 @@ STATUS: OK
---
## 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
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
"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
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
"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 бод.
Монитор порта: **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

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