Compare commits

..
7 Commits
Author SHA1 Message Date
oskarvitalii d53035a5ef Merge dev into master: pump_controller_8_2_OLED_DONE
Добавлен проект контроллера насосной станции на Wemos D1 mini вместе с
его историей: мониторинг двух основных и резервного насоса, два реле
аварий на D3/D4 (модули active-LOW), OLED, веб-панель с настройками и
состоянием реле, уведомления на HTTPS-webhook, настройки в LittleFS.

Также: .gitignore переведён на белый список, чтобы рабочий каталог
D:\Soft\Arduino мог служить рабочим деревом репозитория, не втягивая
в него старые версии скетчей, архивы и драйверы.
2026-08-07 23:20:29 +07:00
oskarvitaliiandClaude Opus 4.8 bda2ecb7a3 Merge dev into master: carousel dot fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:38 +07:00
oskarvitaliiandClaude Opus 4.8 208669d19f Merge dev into master: boot icon carousel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:51:03 +07:00
oskarvitaliiandClaude Opus 4.8 68bdfdc879 Merge dev into master: RU/EN web interface + scrollable forecast log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:09 +07:00
oskarvitaliiandClaude Opus 4.8 2bf49c14ac revert: restore pre-4b59b89 web server (drop chunked API + low-memory auto-reboot)
The chunked API handlers and the maxblock<6000 auto-restart from 4b59b89
broke the web UI on-device; restore the working String-based handlers and
plain loop while keeping the scrollable forecast log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:48:25 +07:00
oskarvitaliiandClaude Opus 4.8 f8294d4aed feat(web): scrollable Forecast log with fixed height
Cap the log panel at ~216px with a thin themed scrollbar so a long
forecast history does not stretch the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 07:08:21 +07:00
oskarvitaliiandClaude Opus 4.8 4b59b8936b fix: stream large API responses to stop heap-exhaustion truncation
Serving /api/history (and /api/forecasts) built a ~12 KB String +
JsonDocument every 15 s; over days this fragmented the ESP8266 heap until
the TCP stack could no longer send a full response, causing the web page
to fail with ERR_CONTENT_LENGTH_MISMATCH. Stream both array endpoints via
chunked transfer (small stack buffer, no big allocations). Add free-heap
to /api/current and Serial, plus a low-memory safety restart (history is
persisted first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 06:57:09 +07:00
5 changed files with 360 additions and 570 deletions
+60 -102
View File
@@ -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-адрес прибора>` в любом браузере в той же сети.
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже — На странице:
лампы датчиков и выходов, в самом низу свёрнутые настройки.
![Вид панели: состояние «Подача есть», лампы насосов и выходов, раскрытые настройки](docs/img/panel.jpg) * три карточки насосов с индикаторами;
* панель состояния системы (`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 бод**
+58 -183
View File
@@ -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. Веб-интерфейс
![Веб-панель контроллера: плашка состояния, лампы датчиков и выходов, раскрытые настройки](docs/img/panel.jpg)
*Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.*
Сервер на порту `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);
}); });
-146
View File
@@ -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>)====="