Compare commits

..
4 Commits
Author SHA1 Message Date
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
4 changed files with 523 additions and 359 deletions
+87 -56
View File
@@ -106,27 +106,33 @@ OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
## 4. Экран ## 4. Экран
``` ```
PUMP CONTROLLER v8 SUPPLY OK
──────────────────────── ────────────────────────
PUMP 1 : RUNNING P1:RUN P2:OFF BK:OFF
PUMP 2 : STOPPED ALARM:OFF BACKUP:OFF
BACKUP : STANDBY
──────────────────────── ────────────────────────
192.168.1.42 192.168.1.128
STATUS: OK
``` ```
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
| Надпись | Значение | | Надпись | Значение |
|---|---| |---|---|
| `PUMP 1/2 : RUNNING` | Насос работает | | `SUPPLY OK` | Норма, подача идёт |
| `PUMP 1/2 : STOPPED` | Насос остановлен | | `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
| `BACKUP : ACTIVE` | Резервный насос запущен | | `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
| `BACKUP : STANDBY` | Резерв в ожидании | | `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
| `WiFi: NO CONNECTION` | Нет связи с сетью | | `STARTING` | Идёт запуск, первые ~4 секунды |
| `INIT...` | Идёт запуск (первые ~4 секунды) |
| `STATUS: OK` | Норма | Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание | основные, `BK` — резерв.
| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** |
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
Нижняя строка — адрес в сети или `no network`, если связи нет.
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
--- ---
@@ -134,57 +140,76 @@ STATUS: OK
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети. Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
На странице: Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
лампы датчиков и выходов, в самом низу свёрнутые настройки.
* три карточки насосов с индикаторами; ### 5.1 Главная плашка
* панель состояния системы (`SYS_OK` или `CRITICAL`);
* **состояние обоих реле** — «Включено» / «Выключено»;
* кнопка **Сбросить Wi-Fi**;
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
Страница обновляется автоматически при изменении состояния насосов или реле. | Плашка | Цвет | Что значит |
|---|---|---|
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
| **Запуск** | серый | Первые ~4 секунды после включения |
### 5.1 Состояние реле Под заголовком — уточнение: какой именно насос работает, или «подачу держит
резерв», если основные стоят, а резерв качает.
Два индикатора показывают, что прибор реально подал на выход: Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
| Индикатор | Значение | **Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
|---|---| а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не ### 5.2 Лампы
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке.
### 5.2 Настройки Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
или «ожидание».
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
поправить одну выдержку, не трогая остальное. Неверное значение так же «разомкнут». Это то, что прибор реально подал на реле.
игнорируется, поэтому опечатка ничего не испортит.
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
проблема в монтаже или в типе релейного модуля, а не в прошивке.
### 5.3 Настройки
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
и не отвлекают.
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
испортит.
| Поле | Что задаёт | Допустимо | По умолчанию | | Поле | Что задаёт | Допустимо | По умолчанию |
|---|---|---|---| |---|---|---|---|
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан | | **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` | | **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 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`.
**На новом приборе адрес уведомлений не задан** — панель покажет **На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
`не задан - уведомления отключены`, сообщения отправляться не будут, всё уведомления отключены», сообщения отправляться не будут, всё остальное работает.
остальное работает.
--- ---
@@ -192,12 +217,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 +250,7 @@ STATUS: OK
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3). `Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите **Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку **Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
доступа. доступа.
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите **Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
@@ -238,15 +269,15 @@ STATUS: OK
| Событие | Задержка | | Событие | Задержка |
|---|---| |---|---|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) | | Фиксация нового состояния датчика | 3 секунды (не настраивается) |
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с | | Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с | | Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** | | Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды | | Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
реле и на уведомление** — отдельно задержать только уведомление нельзя. реле и на уведомление** — отдельно задержать только уведомление нельзя.
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
антидребезга. антидребезга.
--- ---
@@ -266,7 +297,7 @@ STATUS: OK
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки | | Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет | | Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет |
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` | | Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется | | Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание | | Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
Подробный лог работы выводится в USB-порт: скорость **115200 бод** Подробный лог работы выводится в USB-порт: скорость **115200 бод**
+147 -57
View File
@@ -12,7 +12,8 @@
| Файл | Назначение | | Файл | Назначение |
|---|---| |---|---|
| `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` | Техническая документация (этот файл) | | `README.md` | Техническая документация (этот файл) |
| `MANUAL.md` | Руководство пользователя / монтажника | | `MANUAL.md` | Руководство пользователя / монтажника |
| `docs/specs/` | Согласованные проектные решения по крупным изменениям | | `docs/specs/` | Согласованные проектные решения по крупным изменениям |
@@ -213,18 +214,37 @@ pE = стоит -> реле «Резерв» выключено
`POST` на `settings.webhookUrl`, `Content-Type: application/json`: `POST` на `settings.webhookUrl`, `Content-Type: application/json`:
```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` | | `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` | | `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
| `status` | **Человеческая фраза**, та же что в панели |
| `device` | Имя прибора из настроек |
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель, Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
по умолчанию `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()`
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
@@ -395,46 +415,97 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
| Маршрут | Метод | Описание | | Маршрут | Метод | Описание |
|---|---|---| |---|---|---|
| `/` | `GET` | HTML-панель мониторинга | | `/` | `GET` | Статическая HTML-панель из PROGMEM |
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` | | `/status` | `GET` | Живое состояние, JSON |
| `/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 `/set_config` ### 7.1 Почему страница статическая
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
так частично заполненная форма не обнуляет остальные параметры. Запись в файл **14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
выполняется только если что-то реально изменилось. лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
| Параметр | Валидация | При отказе | Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
|---|---|---| `location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
| `url` | 1…220 символов | поле не меняется | пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется | трогается.
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
Фильтрация `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` — это **логическое** состояние реле из переменных `/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
обратное действительности.
### 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 +513,32 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
Обновление раз в секунду (`DISPLAY_INTERVAL`). Обновление раз в секунду (`DISPLAY_INTERVAL`).
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
менялся и ничего не сообщал.
``` ```
PUMP CONTROLLER v8 NO SUPPLY <- кегль 2, до 10 символов
──────────────────────── ────────────────────────
PUMP 1 : RUNNING P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
PUMP 2 : STOPPED ALARM:ON BACKUP:ON <- состояние выходов
BACKUP : STANDBY
──────────────────────── ────────────────────────
192.168.1.42 192.168.1.128
STATUS: OK
``` ```
Строка статуса: | Условие | Слово |
| Условие | Текст |
|---|---| |---|---|
| `!systemReady` | `INIT...` | | `!systemReady` | `STARTING` |
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) | | оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
| работает резерв | `WARN: BACKUP RUNNING` | | оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
| иначе | `STATUS: OK` | | работает резерв, основные в порядке | `BACKUP RUN` |
| иначе | `SUPPLY OK` |
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`. тащить её во флеш ради четырёх слов не стоит.
Состояние обоих реле теперь выводится — освободились две строки после
объединения датчиков в одну.
--- ---
@@ -517,14 +592,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 ```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 ```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` не показывает,
на реальной сборке останавливает всё.
@@ -283,23 +283,26 @@ void settingsBegin() {
} }
// ============================================================ // ============================================================
// OLED — отображение состояния // OLED - макет экрана 128x64
// ============================================================ // ============================================================
/* /*
Макет экрана 128x64 (8 строк по 8px при шрифте 1): Верхняя строка отдана главному ответу кеглем 2 (12x16 px) - читается от
двери. Прежний постоянный заголовок "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
*/ */
// ============================================================ // ============================================================
@@ -316,61 +319,56 @@ 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 символов) ---
display.setTextSize(1); // Верхняя строка отдана статусу: прежний заголовок "PUMP CONTROLLER v8"
display.setCursor(0, 0); // занимал её постоянно, но никогда не менялся и ничего не сообщал.
display.print(F(" PUMP CONTROLLER v8")); const __FlashStringHelper* word;
if (!systemReady) word = F("STARTING");
else if (alarm) word = pE.stableState ? F("BACKUP ON") : F("NO SUPPLY");
else if (pE.stableState) word = F("BACKUP RUN");
else word = F("SUPPLY OK");
// --- Разделитель --- // При аварии строка мигает инверсией каждые 500 мс
display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE); bool blink = alarm && ((millis() / 500) % 2 == 0);
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.print(F("!! ALARM: NO PUMPS !!")); display.setTextSize(2);
display.setCursor(0, 1);
display.print(word);
display.setTextColor(SSD1306_WHITE); display.setTextColor(SSD1306_WHITE);
} else if (emergency) { display.setTextSize(1);
display.print(F("WARN: BACKUP RUNNING"));
} else { display.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE);
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();
} }
@@ -412,7 +410,7 @@ static bool parseWebhookHost(const char* url, String &host) {
return true; return true;
} }
void sendPostWebhook(String eventName, String status) { void sendPostWebhook(String eventName, String state) {
if (settings.webhookUrl[0] == '\0') { if (settings.webhookUrl[0] == '\0') {
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена")); Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
return; return;
@@ -469,9 +467,14 @@ void sendPostWebhook(String eventName, String status) {
} }
http.addHeader("Content-Type", "application/json"); http.addHeader("Content-Type", "application/json");
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование // status — человеческая фраза, та же что в панели: «Насосы стоят, подачу
// в JSON не требуется — сломать тело запроса ему нечем. // держит резерв». state оставлен машиночитаемым (ALARM / OK / STARTED /
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + // STOPPED), чтобы сценарий на сервере ветвился по нему, а не разбирал текст.
// 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);
@@ -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>'>"; static const char* statusKind() {
html += F("<meta name='viewport' content='width=device-width,initial-scale=1'>" if (!systemReady) return "init";
"<title>Мониторинг Насосов</title>" if (!p1.stableState && !p2.stableState) return "flt";
"<style>" if (pE.stableState) return "warn";
"@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Unbounded:wght@400;700&display=swap');" return "ok";
"*{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 __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 // SETUP
// ============================================================ // ============================================================
@@ -793,16 +662,44 @@ 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(200, "text/html", getHTML()); server.send_P(200, PSTR("text/html"), PAGE_HTML);
}); });
// Живое состояние. Опрашивается раз в 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
@@ -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>)====="