Compare commits
3
Commits
42cbb82492
...
0c06ba1032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c06ba1032 | ||
|
|
6240862c27 | ||
|
|
cb151d2e30 |
@@ -106,27 +106,33 @@ OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
|
||||
## 4. Экран
|
||||
|
||||
```
|
||||
PUMP CONTROLLER v8
|
||||
SUPPLY OK
|
||||
────────────────────────
|
||||
PUMP 1 : RUNNING
|
||||
PUMP 2 : STOPPED
|
||||
BACKUP : STANDBY
|
||||
P1:RUN P2:OFF BK:OFF
|
||||
ALARM:OFF BACKUP:OFF
|
||||
────────────────────────
|
||||
192.168.1.42
|
||||
STATUS: OK
|
||||
192.168.1.128
|
||||
```
|
||||
|
||||
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
|
||||
|
||||
| Надпись | Значение |
|
||||
|---|---|
|
||||
| `PUMP 1/2 : RUNNING` | Насос работает |
|
||||
| `PUMP 1/2 : STOPPED` | Насос остановлен |
|
||||
| `BACKUP : ACTIVE` | Резервный насос запущен |
|
||||
| `BACKUP : STANDBY` | Резерв в ожидании |
|
||||
| `WiFi: NO CONNECTION` | Нет связи с сетью |
|
||||
| `INIT...` | Идёт запуск (первые ~4 секунды) |
|
||||
| `STATUS: OK` | Норма |
|
||||
| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание |
|
||||
| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** |
|
||||
| `SUPPLY OK` | Норма, подача идёт |
|
||||
| `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
|
||||
| `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
|
||||
| `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
|
||||
| `STARTING` | Идёт запуск, первые ~4 секунды |
|
||||
|
||||
Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
|
||||
основные, `BK` — резерв.
|
||||
|
||||
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
|
||||
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
|
||||
|
||||
Нижняя строка — адрес в сети или `no network`, если связи нет.
|
||||
|
||||
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
|
||||
|
||||
---
|
||||
|
||||
@@ -134,57 +140,76 @@ STATUS: OK
|
||||
|
||||
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
|
||||
|
||||
На странице:
|
||||
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
|
||||
лампы датчиков и выходов, в самом низу свёрнутые настройки.
|
||||
|
||||
* три карточки насосов с индикаторами;
|
||||
* панель состояния системы (`SYS_OK` или `CRITICAL`);
|
||||
* **состояние обоих реле** — «Включено» / «Выключено»;
|
||||
* кнопка **Сбросить Wi-Fi**;
|
||||
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
|
||||
### 5.1 Главная плашка
|
||||
|
||||
Страница обновляется автоматически при изменении состояния насосов или реле.
|
||||
| Плашка | Цвет | Что значит |
|
||||
|---|---|---|
|
||||
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
|
||||
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
|
||||
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
|
||||
| **Запуск** | серый | Первые ~4 секунды после включения |
|
||||
|
||||
### 5.1 Состояние реле
|
||||
Под заголовком — уточнение: какой именно насос работает, или «подачу держит
|
||||
резерв», если основные стоят, а резерв качает.
|
||||
|
||||
Два индикатора показывают, что прибор реально подал на выход:
|
||||
Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
|
||||
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
||||
|
||||
| Индикатор | Значение |
|
||||
|---|---|
|
||||
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
|
||||
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
|
||||
**Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
|
||||
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
|
||||
|
||||
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не
|
||||
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
||||
### 5.2 Лампы
|
||||
|
||||
### 5.2 Настройки
|
||||
Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
|
||||
или «ожидание».
|
||||
|
||||
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно
|
||||
поправить одну выдержку, не трогая остальное. Неверное значение так же
|
||||
игнорируется, поэтому опечатка ничего не испортит.
|
||||
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
|
||||
«разомкнут». Это то, что прибор реально подал на реле.
|
||||
|
||||
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
|
||||
проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
||||
|
||||
### 5.3 Настройки
|
||||
|
||||
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
|
||||
и не отвлекают.
|
||||
|
||||
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
|
||||
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
|
||||
испортит.
|
||||
|
||||
| Поле | Что задаёт | Допустимо | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан |
|
||||
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
||||
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
|
||||
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
|
||||
| **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
|
||||
| **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
||||
| **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
|
||||
| **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
|
||||
|
||||
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
|
||||
секретный ключ, и выводить его на экран, который видно всем в помещении, не
|
||||
нужно. Чтобы сменить адрес — введите новый целиком.
|
||||
|
||||
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
|
||||
(см. раздел 7).
|
||||
|
||||
Настройки сохраняются в файл во внутренней памяти прибора, переживают
|
||||
отключение питания и сброс Wi-Fi.
|
||||
отключение питания и смену сети Wi-Fi.
|
||||
|
||||
> Если панель показывает красное предупреждение «Файловая система недоступна»
|
||||
> или настройки пропадают после перезагрузки — причина в настройке `Flash Size`
|
||||
> Если панель показывает красное предупреждение «Память недоступна» или
|
||||
> настройки пропадают после перезагрузки — причина в настройке `Flash Size`
|
||||
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
|
||||
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
|
||||
> 2 МБ приводит ровно к такому симптому.
|
||||
|
||||
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
|
||||
> Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
|
||||
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
||||
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
|
||||
|
||||
**На новом приборе адрес уведомлений не задан** — панель покажет
|
||||
`не задан - уведомления отключены`, сообщения отправляться не будут, всё
|
||||
остальное работает.
|
||||
**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
|
||||
уведомления отключены», сообщения отправляться не будут, всё остальное работает.
|
||||
|
||||
---
|
||||
|
||||
@@ -192,12 +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).
|
||||
|
||||
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
|
||||
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку
|
||||
**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
|
||||
доступа.
|
||||
|
||||
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
|
||||
@@ -238,15 +269,15 @@ STATUS: OK
|
||||
| Событие | Задержка |
|
||||
|---|---|
|
||||
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
|
||||
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с |
|
||||
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с |
|
||||
| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
|
||||
| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
|
||||
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
|
||||
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
|
||||
|
||||
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на
|
||||
Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
|
||||
реле и на уведомление** — отдельно задержать только уведомление нельзя.
|
||||
|
||||
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
|
||||
Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
|
||||
антидребезга.
|
||||
|
||||
---
|
||||
@@ -266,7 +297,7 @@ STATUS: OK
|
||||
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
|
||||
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет |
|
||||
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
|
||||
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
||||
| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
||||
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
|
||||
|
||||
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
||||
|
||||
@@ -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` | Техническая документация (этот файл) |
|
||||
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||||
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
||||
@@ -213,18 +214,37 @@ pE = стоит -> реле «Резерв» выключено
|
||||
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
|
||||
{"event":"MAIN PUMPS","state":"ALARM",
|
||||
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
|
||||
```
|
||||
|
||||
| `event` | `status` |
|
||||
| Поле | Назначение |
|
||||
|---|---|
|
||||
| `MAIN PUMPS` | `ALARM` / `OK` |
|
||||
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
|
||||
| `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
|
||||
| `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
|
||||
| `status` | **Человеческая фраза**, та же что в панели |
|
||||
| `device` | Имя прибора из настроек |
|
||||
|
||||
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
|
||||
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
|
||||
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
|
||||
запроса ему нечем.
|
||||
Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
|
||||
момент отправки — это те же слова, что видит оператор в панели:
|
||||
|
||||
| Ситуация | `status` |
|
||||
|---|---|
|
||||
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
|
||||
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
|
||||
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
|
||||
| Норма | `Подача есть, работает насос 1` |
|
||||
|
||||
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
|
||||
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
|
||||
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
|
||||
|
||||
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
|
||||
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
|
||||
разошлись бы при первой правке.
|
||||
|
||||
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
|
||||
поэтому экранирование в JSON не требуется нигде.
|
||||
|
||||
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` —
|
||||
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
||||
@@ -395,46 +415,97 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
|
||||
|
||||
| Маршрут | Метод | Описание |
|
||||
|---|---|---|
|
||||
| `/` | `GET` | HTML-панель мониторинга |
|
||||
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` |
|
||||
| `/` | `GET` | Статическая HTML-панель из PROGMEM |
|
||||
| `/status` | `GET` | Живое состояние, JSON |
|
||||
| `/config` | `GET` | Настройки и адрес в сети, JSON |
|
||||
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
||||
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
|
||||
|
||||
### 7.1 `/set_config`
|
||||
### 7.1 Почему страница статическая
|
||||
|
||||
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** —
|
||||
так частично заполненная форма не обнуляет остальные параметры. Запись в файл
|
||||
выполняется только если что-то реально изменилось.
|
||||
Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
|
||||
**14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
|
||||
лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
|
||||
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
|
||||
|
||||
| Параметр | Валидация | При отказе |
|
||||
|---|---|---|
|
||||
| `url` | 1…220 символов | поле не меняется |
|
||||
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется |
|
||||
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
|
||||
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
|
||||
Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
|
||||
`location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
|
||||
пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
|
||||
трогается.
|
||||
|
||||
Фильтрация `device` вместо экранирования выбрана осознанно: разрешённый набор
|
||||
символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно
|
||||
нигде.
|
||||
Ответы:
|
||||
|
||||
### 7.2 Состояние реле
|
||||
```json
|
||||
/status {"p1":1,"p2":0,"pE":0,"rMain":0,"rEmg":0,"rdy":1,
|
||||
"k":"ok","w":"Подача есть","s":"работает насос 1"}
|
||||
/config {"device":"Nasosnaya_1","alarm":5,"emerg":0,
|
||||
"host":"n8n.example.com","fs":1,"ip":"192.168.1.128"}
|
||||
```
|
||||
|
||||
`rMain` и `rEmg` в `/status` — это **логическое** состояние реле из переменных
|
||||
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано
|
||||
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
|
||||
обратное действительности.
|
||||
`/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
|
||||
секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
|
||||
|
||||
### 7.3 Автообновление
|
||||
### 7.2 Логика статуса и цвета
|
||||
|
||||
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при любом
|
||||
изменении JSON. Поскольку состояние реле теперь входит в ответ, страница
|
||||
обновляется и при срабатывании реле. Шрифты подгружаются с Google Fonts — при
|
||||
отсутствии интернета у клиента интерфейс отрисуется системным моноширинным
|
||||
шрифтом.
|
||||
Цвета взяты по конвенции сигнальных ламп, а не по вкусу:
|
||||
|
||||
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить webhook
|
||||
и изменить выдержки. Контроллер рассчитан на изолированный технологический
|
||||
сегмент.
|
||||
| Состояние | Цвет | Заголовок | Условие |
|
||||
|---|---|---|---|
|
||||
| Запуск | серый | «Запуск» | `rdy = 0` |
|
||||
| Норма | зелёный | «Подача есть» | работает хотя бы один основной |
|
||||
| Внимание | **жёлтый** | «Работает резерв» | работает резерв, основные в порядке |
|
||||
| Авария | красный | «Насосы стоят» | оба основных стоят |
|
||||
|
||||
Резерв намеренно **жёлтый, а не красный**: работающий резерв — предупреждение,
|
||||
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
||||
|
||||
При аварии плашка медленно пульсирует; пульсация отключается при
|
||||
`prefers-reduced-motion`.
|
||||
|
||||
### 7.3 Состояние реле
|
||||
|
||||
`rMain` и `rEmg` — **логическое** состояние из переменных `relayMainOn` /
|
||||
`relayEmergOn`, а не `digitalRead()` с пина. Выходы инверсные, и чтение уровня
|
||||
показало бы на панели обратное действительности.
|
||||
|
||||
Лампа выхода «Авария» — **красная**. В первой версии панели она была зелёной:
|
||||
стиль «включено» был общим для всех ламп, и сработавшая аварийная сигнализация
|
||||
подсвечивалась цветом «всё хорошо».
|
||||
|
||||
### 7.4 Никаких внешних запросов
|
||||
|
||||
Веб-шрифты не подключаются. В первой версии был `@import` с Google Fonts, но
|
||||
сеть на объекте не разрешает внешние имена (раздел 5.1), поэтому шрифты там не
|
||||
загружались никогда — нарисованный макет на месте не появлялся. Сейчас
|
||||
используются системные стеки, а характер задан контрастом кеглей: заголовок
|
||||
статуса `clamp(28px, 8.5vw, 44px)` против меток 11–12 px.
|
||||
|
||||
### 7.5 Подпись состояния во вкладке
|
||||
|
||||
Заголовок вкладки и favicon меняют цвет вместе со статусом: `Насосы стоят ·
|
||||
Nasosnaya_1` с красным кружком. Вкладка, забытая в фоне на телефоне, продолжает
|
||||
сигналить, не требуя переключения на неё.
|
||||
|
||||
### 7.7 Почему разметка лежит в отдельном файле
|
||||
|
||||
`web_page.h` вынесен не ради опрятности, а вынужденно. Генератор прототипов
|
||||
Arduino не понимает сырые строковые литералы (`R"=====( ... )====="`): он находит
|
||||
внутри JavaScript строку `function draw(d){`, принимает её за определение функции
|
||||
C++ и вставляет в начало файла прототип `function draw(d);`. Сборка падает с
|
||||
`error: 'function' does not name a type`, причём директивы `#line` показывают
|
||||
ошибку на строке внутри HTML.
|
||||
|
||||
Заголовочные файлы препроцессор не сканирует, поэтому страница живёт там.
|
||||
|
||||
Важное следствие для проверки: обычный `g++ -fsyntax-only` по `.ino` эту ошибку
|
||||
**не покажет** — он компилирует файл как обычный `.cpp`, минуя препроцессор
|
||||
Arduino. Проверять сборку нужно через `arduino-cli` (раздел 11.1).
|
||||
|
||||
### 7.6 Доступ
|
||||
|
||||
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить адрес
|
||||
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
|
||||
Контроллер рассчитан на изолированный технологический сегмент.
|
||||
|
||||
---
|
||||
|
||||
@@ -442,28 +513,32 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
|
||||
|
||||
Обновление раз в секунду (`DISPLAY_INTERVAL`).
|
||||
|
||||
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
|
||||
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
|
||||
менялся и ничего не сообщал.
|
||||
|
||||
```
|
||||
PUMP CONTROLLER v8
|
||||
NO SUPPLY <- кегль 2, до 10 символов
|
||||
────────────────────────
|
||||
PUMP 1 : RUNNING
|
||||
PUMP 2 : STOPPED
|
||||
BACKUP : STANDBY
|
||||
P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
|
||||
ALARM:ON BACKUP:ON <- состояние выходов
|
||||
────────────────────────
|
||||
192.168.1.42
|
||||
STATUS: OK
|
||||
192.168.1.128
|
||||
```
|
||||
|
||||
Строка статуса:
|
||||
|
||||
| Условие | Текст |
|
||||
| Условие | Слово |
|
||||
|---|---|
|
||||
| `!systemReady` | `INIT...` |
|
||||
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) |
|
||||
| работает резерв | `WARN: BACKUP RUNNING` |
|
||||
| иначе | `STATUS: OK` |
|
||||
| `!systemReady` | `STARTING` |
|
||||
| оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
|
||||
| оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
|
||||
| работает резерв, основные в порядке | `BACKUP RUN` |
|
||||
| иначе | `SUPPLY OK` |
|
||||
|
||||
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны
|
||||
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`.
|
||||
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
|
||||
тащить её во флеш ради четырёх слов не стоит.
|
||||
|
||||
Состояние обоих реле теперь выводится — освободились две строки после
|
||||
объединения датчиков в одну.
|
||||
|
||||
---
|
||||
|
||||
@@ -517,14 +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
|
||||
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 --warnings all .
|
||||
```
|
||||
|
||||
```bash
|
||||
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" upload -p COM7 --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 .
|
||||
```
|
||||
|
||||
Монитор порта: 115200 бод.
|
||||
Монитор порта: **115200 бод**. Если заливка падает с
|
||||
`could not open port 'COM7': PermissionError`, порт держит открытый монитор
|
||||
Arduino IDE — закройте его.
|
||||
|
||||
### 11.1 Проверять сборку только через arduino-cli
|
||||
|
||||
Скетч нельзя надёжно проверить, скормив `.ino` напрямую в `g++`: так
|
||||
пропускается препроцессор Arduino, а именно он ломается на некоторых
|
||||
конструкциях (раздел 7.7). Ошибка, которую `g++ -fsyntax-only` не показывает,
|
||||
на реальной сборке останавливает всё.
|
||||
|
||||
@@ -283,23 +283,26 @@ void settingsBegin() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OLED — отображение состояния
|
||||
// OLED - макет экрана 128x64
|
||||
// ============================================================
|
||||
/*
|
||||
Макет экрана 128x64 (8 строк по 8px при шрифте 1):
|
||||
Верхняя строка отдана главному ответу кеглем 2 (12x16 px) - читается от
|
||||
двери. Прежний постоянный заголовок "PUMP CONTROLLER v8" её занимал, но
|
||||
ничего не сообщал. При аварии строка мигает инверсией раз в 500 мс.
|
||||
|
||||
┌──────────────────────────┐
|
||||
│ PUMP CONTROLLER │ строка 0 — заголовок
|
||||
│ ────────────────────── │ строка 1 — разделитель
|
||||
│ НАСОС 1: РАБОТАЕТ │ строка 2
|
||||
│ НАСОС 2: СТОИТ │ строка 3
|
||||
│ РЕЗЕРВ : СТОИТ │ строка 4
|
||||
│ ────────────────────── │ строка 5 — разделитель
|
||||
│ WiFi: 192.168.1.42 │ строка 6
|
||||
│ СТАТУС: НОРМА │ строка 7
|
||||
└──────────────────────────┘
|
||||
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не
|
||||
содержит, а тащить её во флеш ради четырёх слов не стоит.
|
||||
|
||||
При аварии строка 7 мигает "!! АВАРИЯ !!"
|
||||
+--------------------------+
|
||||
| NO SUPPLY | кегль 2, до 10 символов
|
||||
|--------------------------|
|
||||
| P1:OFF P2:OFF BK:RUN | датчики, одна строка
|
||||
| ALARM:ON BACKUP:ON | состояние выходов
|
||||
|--------------------------|
|
||||
| 192.168.1.128 |
|
||||
+--------------------------+
|
||||
|
||||
Слова статуса: STARTING / SUPPLY OK / BACKUP RUN / BACKUP ON / NO SUPPLY
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
@@ -316,61 +319,56 @@ void setRelay(int pin, bool on) {
|
||||
void updateDisplay() {
|
||||
if (!oledOK) return;
|
||||
|
||||
bool alarm = (!p1.stableState && !p2.stableState);
|
||||
|
||||
display.clearDisplay();
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// --- Заголовок ---
|
||||
display.setTextSize(1);
|
||||
display.setCursor(0, 0);
|
||||
display.print(F(" PUMP CONTROLLER v8"));
|
||||
// --- Главный ответ, кегль 2 (12x16 px, до 10 символов) ---
|
||||
// Верхняя строка отдана статусу: прежний заголовок "PUMP CONTROLLER v8"
|
||||
// занимал её постоянно, но никогда не менялся и ничего не сообщал.
|
||||
const __FlashStringHelper* word;
|
||||
if (!systemReady) word = F("STARTING");
|
||||
else if (alarm) word = pE.stableState ? F("BACKUP ON") : F("NO SUPPLY");
|
||||
else if (pE.stableState) word = F("BACKUP RUN");
|
||||
else word = F("SUPPLY OK");
|
||||
|
||||
// --- Разделитель ---
|
||||
display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE);
|
||||
|
||||
// --- Состояния насосов ---
|
||||
display.setCursor(0, 14);
|
||||
display.print(F("PUMP 1 : "));
|
||||
display.print(p1.stableState ? F("RUNNING") : F("STOPPED"));
|
||||
|
||||
display.setCursor(0, 24);
|
||||
display.print(F("PUMP 2 : "));
|
||||
display.print(p2.stableState ? F("RUNNING") : F("STOPPED"));
|
||||
|
||||
display.setCursor(0, 34);
|
||||
display.print(F("BACKUP : "));
|
||||
display.print(pE.stableState ? F("ACTIVE ") : F("STANDBY"));
|
||||
|
||||
// --- Разделитель ---
|
||||
display.drawFastHLine(0, 44, SCREEN_WIDTH, SSD1306_WHITE);
|
||||
|
||||
// --- IP / статус WiFi ---
|
||||
display.setCursor(0, 47);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
display.print(WiFi.localIP().toString());
|
||||
} else {
|
||||
display.print(F("WiFi: NO CONNECTION"));
|
||||
}
|
||||
|
||||
// --- Строка статуса ---
|
||||
bool alarm = (!p1.stableState && !p2.stableState);
|
||||
bool emergency = pE.stableState;
|
||||
|
||||
display.setCursor(0, 56);
|
||||
if (!systemReady) {
|
||||
display.print(F("INIT..."));
|
||||
} else if (alarm) {
|
||||
// Мигание каждые 500 мс
|
||||
if ((millis() / 500) % 2 == 0) {
|
||||
display.fillRect(0, 54, SCREEN_WIDTH, 10, SSD1306_WHITE);
|
||||
// При аварии строка мигает инверсией каждые 500 мс
|
||||
bool blink = alarm && ((millis() / 500) % 2 == 0);
|
||||
if (blink) {
|
||||
display.fillRect(0, 0, SCREEN_WIDTH, 18, SSD1306_WHITE);
|
||||
display.setTextColor(SSD1306_BLACK);
|
||||
}
|
||||
display.print(F("!! ALARM: NO PUMPS !!"));
|
||||
display.setTextSize(2);
|
||||
display.setCursor(0, 1);
|
||||
display.print(word);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
} else if (emergency) {
|
||||
display.print(F("WARN: BACKUP RUNNING"));
|
||||
} else {
|
||||
display.print(F("STATUS: OK"));
|
||||
}
|
||||
display.setTextSize(1);
|
||||
|
||||
display.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE);
|
||||
|
||||
// --- Датчики: три поля в одной строке вместо трёх строк ---
|
||||
display.setCursor(0, 25);
|
||||
display.print(F("P1:"));
|
||||
display.print(p1.stableState ? F("RUN") : F("OFF"));
|
||||
display.print(F(" P2:"));
|
||||
display.print(p2.stableState ? F("RUN") : F("OFF"));
|
||||
display.print(F(" BK:"));
|
||||
display.print(pE.stableState ? F("RUN") : F("OFF"));
|
||||
|
||||
// --- Выходы на реле ---
|
||||
display.setCursor(0, 37);
|
||||
display.print(F("ALARM:"));
|
||||
display.print(relayMainOn ? F("ON ") : F("OFF"));
|
||||
display.print(F(" BACKUP:"));
|
||||
display.print(relayEmergOn ? F("ON") : F("OFF"));
|
||||
|
||||
display.drawFastHLine(0, 49, SCREEN_WIDTH, SSD1306_WHITE);
|
||||
|
||||
// --- Адрес в сети ---
|
||||
display.setCursor(0, 54);
|
||||
if (WiFi.status() == WL_CONNECTED) display.print(WiFi.localIP().toString());
|
||||
else display.print(F("no network"));
|
||||
|
||||
display.display();
|
||||
}
|
||||
@@ -412,7 +410,7 @@ static bool parseWebhookHost(const char* url, String &host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void sendPostWebhook(String eventName, String status) {
|
||||
void sendPostWebhook(String eventName, String state) {
|
||||
if (settings.webhookUrl[0] == '\0') {
|
||||
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
||||
return;
|
||||
@@ -469,9 +467,14 @@ void sendPostWebhook(String eventName, String status) {
|
||||
}
|
||||
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
|
||||
// в JSON не требуется — сломать тело запроса ему нечем.
|
||||
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status +
|
||||
// status — человеческая фраза, та же что в панели: «Насосы стоят, подачу
|
||||
// держит резерв». state оставлен машиночитаемым (ALARM / OK / STARTED /
|
||||
// STOPPED), чтобы сценарий на сервере ветвился по нему, а не разбирал текст.
|
||||
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], фразы кавычек
|
||||
// и обратных слэшей не содержат — экранирование в JSON нигде не нужно.
|
||||
String body = "{\"event\":\"" + eventName +
|
||||
"\",\"state\":\"" + state +
|
||||
"\",\"status\":\"" + statusPhrase() +
|
||||
"\",\"device\":\"" + String(settings.deviceName) + "\"}";
|
||||
int code = http.POST(body);
|
||||
|
||||
@@ -534,190 +537,56 @@ void updatePumpState(Pump &p) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTML интерфейс (без изменений логики, только IP-адрес актуален)
|
||||
// Словесное описание состояния
|
||||
// ============================================================
|
||||
String getHTML() {
|
||||
bool isAlarm = (!p1.stableState && !p2.stableState);
|
||||
// Единственное место, где живёт формулировка. Её берут и веб-панель (через
|
||||
// /status), и тело webhook — иначе два набора фраз разошлись бы при первой
|
||||
// же правке. Фразы не содержат кавычек и обратных слэшей, поэтому безопасно
|
||||
// вставляются в JSON без экранирования.
|
||||
|
||||
String html = F("<!DOCTYPE html><html lang='ru'><head><meta charset='UTF-8'>");
|
||||
html += "<link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><circle cx=%2250%22 cy=%2250%22 r=%2245%22 fill=%22%23070b12%22 stroke=%22%232ea043%22 stroke-width=%228%22/><circle cx=%2250%22 cy=%2250%22 r=%2220%22 fill=%22none%22 stroke=%22%232ea043%22 stroke-width=%226%22/><line x1=%2250%22 y1=%225%22 x2=%2250%22 y2=%2222%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2250%22 y1=%2278%22 x2=%2250%22 y2=%2295%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%225%22 y1=%2250%22 x2=%2222%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/><line x1=%2278%22 y1=%2250%22 x2=%2295%22 y2=%2250%22 stroke=%22%232ea043%22 stroke-width=%226%22 stroke-linecap=%22round%22/></svg>'>";
|
||||
html += F("<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>Мониторинг Насосов</title>"
|
||||
"<style>"
|
||||
"@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Unbounded:wght@400;700&display=swap');"
|
||||
"*{box-sizing:border-box;margin:0;padding:0}"
|
||||
"body{font-family:'JetBrains Mono',monospace;background:#070b12;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}"
|
||||
".dash{background:#070b12;border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:28px 24px;width:100%;max-width:520px;position:relative;overflow:hidden}"
|
||||
".dash::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 80% 20%,rgba(20,80,160,0.18) 0%,transparent 70%),radial-gradient(ellipse 40% 30% at 10% 80%,rgba(10,120,80,0.12) 0%,transparent 60%);pointer-events:none}"
|
||||
".grid-bg{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.025) 1px,transparent 1px);background-size:40px 40px;pointer-events:none}"
|
||||
".header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px;position:relative;z-index:1}"
|
||||
".sys-label{font-family:'Unbounded',monospace;font-size:9px;letter-spacing:0.2em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:4px}"
|
||||
".title{font-family:'Unbounded',monospace;font-size:17px;font-weight:700;color:#e8edf5;letter-spacing:0.02em}"
|
||||
".refresh-badge{display:flex;align-items:center;gap:6px;font-size:10px;color:rgba(255,255,255,0.3);letter-spacing:0.08em;margin-top:6px}"
|
||||
".pulse-dot{width:6px;height:6px;border-radius:50%;background:#2ea043;animation:livePulse 2s ease-in-out infinite}"
|
||||
"@keyframes livePulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}}"
|
||||
".time-block{text-align:right}.time-val{font-size:20px;font-weight:700;color:rgba(255,255,255,0.55);letter-spacing:0.05em}.date-val{font-size:10px;color:rgba(255,255,255,0.2);letter-spacing:0.1em;margin-top:2px}"
|
||||
".pumps-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;position:relative;z-index:1}"
|
||||
".pump-card{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:14px 12px;position:relative;overflow:hidden}"
|
||||
".pump-card.active{border-color:rgba(46,160,67,0.4);background:rgba(46,160,67,0.05)}"
|
||||
".pump-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,#2ea043,transparent);animation:scanLine 3s linear infinite}"
|
||||
".pump-card.emergency.active{border-color:rgba(218,54,51,0.5);background:rgba(218,54,51,0.06)}"
|
||||
".pump-card.emergency.active::after{background:linear-gradient(90deg,transparent,#da3633,transparent)}"
|
||||
"@keyframes scanLine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}"
|
||||
".pump-type{font-size:8px;letter-spacing:0.18em;color:rgba(255,255,255,0.22);text-transform:uppercase;margin-bottom:8px}.pump-type.emerg{color:rgba(218,54,51,0.45)}"
|
||||
".pump-icon{width:32px;height:32px;margin-bottom:8px}.pump-name{font-size:11px;font-weight:500;color:#c9d1d9;margin-bottom:10px;line-height:1.4}"
|
||||
".pump-status{display:flex;align-items:center;gap:6px}.led{width:7px;height:7px;border-radius:50%;flex-shrink:0}"
|
||||
".led.on{background:#2ea043;box-shadow:0 0 6px rgba(46,160,67,0.8);animation:ledGlow 2s ease-in-out infinite}.led.on.emerg{background:#da3633;box-shadow:0 0 6px rgba(218,54,51,0.8)}.led.off{background:rgba(255,255,255,0.12)}"
|
||||
"@keyframes ledGlow{0%,100%{opacity:1}50%{opacity:0.55}}.stext{font-size:9px;letter-spacing:0.1em;text-transform:uppercase;font-weight:500}"
|
||||
".stext.on{color:#2ea043}.stext.on.emerg{color:#da3633}.stext.off{color:rgba(255,255,255,0.2)}"
|
||||
".meter{margin-top:10px;height:3px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}.mfill{height:100%;border-radius:2px;background:#2ea043;transition:width 1s ease}.mfill.emerg{background:#da3633}"
|
||||
".bottom-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;position:relative;z-index:1}"
|
||||
".spanel{border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}.spanel.ok{background:rgba(35,134,54,0.12);border:1px solid rgba(46,160,67,0.3)}"
|
||||
".spanel.crit{background:rgba(248,81,73,0.08);border:1px solid rgba(248,81,73,0.3)}"
|
||||
".sicon{width:26px;height:26px;flex-shrink:0}"
|
||||
".scode{font-size:8px;letter-spacing:0.15em;text-transform:uppercase;margin-bottom:3px}.scode.ok{color:#2ea043}.scode.crit{color:#f85149}"
|
||||
".smsg{font-size:11px;font-weight:500}.smsg.ok{color:#c9d1d9}.smsg.crit{color:#f85149}"
|
||||
".cpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||
".clabel{font-size:8px;letter-spacing:0.15em;color:rgba(255,255,255,0.25);text-transform:uppercase;margin-bottom:8px}"
|
||||
".cbtn{width:100%;padding:7px 0;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
|
||||
".cbtn:hover{background:rgba(255,255,255,0.1)}"
|
||||
".wh-row{margin-top:10px;position:relative;z-index:1}"
|
||||
".wh-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||
".wh-url{font-size:9px;color:rgba(255,255,255,0.35);word-break:break-all;margin-bottom:8px}"
|
||||
".wh-input{width:100%;padding:6px 8px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:#c9d1d9;font-family:'JetBrains Mono',monospace;font-size:10px}"
|
||||
".cbtn-save{width:100%;padding:7px 0;background:rgba(46,160,67,0.15);border:1px solid rgba(46,160,67,0.3);border-radius:6px;color:#2ea043;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}"
|
||||
".emg-row{margin-top:10px;position:relative;z-index:1}"
|
||||
".emg-panel{background:rgba(248,81,73,0.06);border:1px solid rgba(248,81,73,0.25);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}"
|
||||
".relay-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px;position:relative;z-index:1}"
|
||||
".rpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||
".rpanel.on{border-color:rgba(46,160,67,0.35);background:rgba(46,160,67,0.06)}"
|
||||
".rpanel.on.emerg{border-color:rgba(218,54,51,0.4);background:rgba(218,54,51,0.06)}"
|
||||
".rstate{display:flex;align-items:center;gap:6px;margin-top:6px}"
|
||||
".cfg-row{margin-top:10px;position:relative;z-index:1}"
|
||||
".cfg-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}"
|
||||
".cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}"
|
||||
".cfg-f{display:flex;flex-direction:column;gap:4px}"
|
||||
".cfg-l{font-size:9px;letter-spacing:0.08em;color:rgba(255,255,255,0.3);text-transform:uppercase}"
|
||||
".cfg-wide{grid-column:1 / -1}"
|
||||
"</style></head><body><div class='dash'><div class='grid-bg'></div>");
|
||||
|
||||
html += F("<div class='header'>"
|
||||
"<div><div class='sys-label'>SYS · PUMP_CTRL</div>"
|
||||
"<div class='title'>Мониторинг<br>Насосов</div>"
|
||||
"<div class='refresh-badge'><div class='pulse-dot'></div>LIVE · AUTO</div></div>"
|
||||
"<div class='time-block'><div class='time-val' id='clk'>--:--:--</div><div class='date-val' id='dt'></div></div>"
|
||||
"</div>");
|
||||
|
||||
html += "<div class='pumps-grid'>";
|
||||
|
||||
// Насос 1
|
||||
html += "<div class='pump-card" + String(p1.stableState ? " active" : "") + "'>";
|
||||
html += F("<div class='pump-type'>Насос</div>"
|
||||
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"</svg>");
|
||||
html += "<div class='pump-name'>Основной 1</div><div class='pump-status'><div class='led " + String(p1.stableState ? "on" : "off") + "'></div><div class='stext " + String(p1.stableState ? "on" : "off") + "'>" + String(p1.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p1.stableState ? "80" : "0") + "%'></div></div></div>";
|
||||
|
||||
// Насос 2
|
||||
html += "<div class='pump-card" + String(p2.stableState ? " active" : "") + "'>";
|
||||
html += F("<div class='pump-type'>Насос</div>"
|
||||
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||
"<circle cx='16' cy='16' r='14' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||
"<circle cx='16' cy='16' r='6' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||
"<line x1='16' y1='2' x2='16' y2='8' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<line x1='16' y1='24' x2='16' y2='30' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<line x1='2' y1='16' x2='8' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<line x1='24' y1='16' x2='30' y2='16' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"</svg>");
|
||||
html += "<div class='pump-name'>Основной 2</div><div class='pump-status'><div class='led " + String(p2.stableState ? "on" : "off") + "'></div><div class='stext " + String(p2.stableState ? "on" : "off") + "'>" + String(p2.stableState ? "Работает" : "Стоит") + "</div></div><div class='meter'><div class='mfill' style='width:" + String(p2.stableState ? "80" : "0") + "%'></div></div></div>";
|
||||
|
||||
// Резерв
|
||||
html += "<div class='pump-card emergency" + String(pE.stableState ? " active" : "") + "'>";
|
||||
html += F("<div class='pump-type emerg'>Резерв</div>"
|
||||
"<svg class='pump-icon' viewBox='0 0 32 32' fill='none'>"
|
||||
"<path d='M16 3 L28 27 L4 27 Z' stroke='currentColor' stroke-width='1.5' fill='none'/>"
|
||||
"<line x1='16' y1='12' x2='16' y2='20' stroke='currentColor' stroke-width='2' stroke-linecap='round'/>"
|
||||
"<circle cx='16' cy='23' r='1.5' fill='currentColor'/>"
|
||||
"</svg>");
|
||||
html += "<div class='pump-name'>Резерв</div><div class='pump-status'><div class='led " + String(pE.stableState ? "on emerg" : "off") + "'></div><div class='stext " + String(pE.stableState ? "on emerg" : "off") + "'>" + String(pE.stableState ? "Активен" : "Ожидание") + "</div></div><div class='meter'><div class='mfill" + String(pE.stableState ? " emerg" : "") + "' style='width:" + String(pE.stableState ? "55" : "0") + "%'></div></div></div>";
|
||||
|
||||
html += "</div><div class='bottom-row'>";
|
||||
|
||||
if (isAlarm) {
|
||||
html += "<div class='spanel crit'><svg class='sicon' viewBox='0 0 26 26' fill='none'><path d='M13 3 L23 21 L3 21 Z' stroke='#f85149' stroke-width='1.5' fill='rgba(248,81,73,0.1)'/><line x1='13' y1='10' x2='13' y2='16' stroke='#f85149' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode crit'>CRITICAL · 0xFF</div><div class='smsg crit'>Нет подачи!</div></div></div>";
|
||||
} else {
|
||||
html += "<div class='spanel ok'><svg class='sicon' viewBox='0 0 26 26' fill='none'><circle cx='13' cy='13' r='10' stroke='#2ea043' stroke-width='1.5'/><path d='M8 13 L11.5 16.5 L18 10' stroke='#2ea043' stroke-width='2' stroke-linecap='round'/></svg><div><div class='scode ok'>SYS_OK · 0x00</div><div class='smsg ok'>Система: норма</div></div></div>";
|
||||
// Вид состояния: им панель выбирает цвет плашки.
|
||||
static const char* statusKind() {
|
||||
if (!systemReady) return "init";
|
||||
if (!p1.stableState && !p2.stableState) return "flt";
|
||||
if (pE.stableState) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
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>";
|
||||
// Главное слово — крупная строка в панели и начало фразы в уведомлении.
|
||||
static const __FlashStringHelper* statusWord() {
|
||||
if (!systemReady) return F("Запуск");
|
||||
if (!p1.stableState && !p2.stableState) return F("Насосы стоят");
|
||||
if (pE.stableState) return F("Работает резерв");
|
||||
return F("Подача есть");
|
||||
}
|
||||
|
||||
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* statusSub() {
|
||||
if (!systemReady) return F("идёт проверка датчиков");
|
||||
if (!p1.stableState && !p2.stableState) {
|
||||
return pE.stableState ? F("подачу держит резерв") : F("подачи нет");
|
||||
}
|
||||
if (pE.stableState) return F("основные насосы в работе");
|
||||
if (p1.stableState && p2.stableState) return F("работают оба насоса");
|
||||
return p1.stableState ? F("работает насос 1") : F("работает насос 2");
|
||||
}
|
||||
|
||||
// Готовая фраза целиком: «Насосы стоят, подачу держит резерв».
|
||||
static String statusPhrase() {
|
||||
return String(statusWord()) + ", " + String(statusSub());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ВЕБ-ПАНЕЛЬ
|
||||
// ============================================================
|
||||
// Разметка вынесена в web_page.h намеренно, а не для красоты: генератор
|
||||
// прототипов Arduino не понимает сырые строковые литералы и принимает
|
||||
// "function draw(d){" внутри JS за определение функции C++, после чего
|
||||
// вставляет для него прототип и сборка падает. Заголовочные файлы он не
|
||||
// сканирует. Обычный g++ такую ошибку не показывает — она видна только
|
||||
// при сборке через arduino-cli или IDE.
|
||||
#include "web_page.h"
|
||||
;
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
@@ -793,16 +662,44 @@ void setup() {
|
||||
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
|
||||
|
||||
// Web-сервер
|
||||
// Страница отдаётся прямо из флеша, минуя кучу.
|
||||
server.on("/", HTTP_GET, []() {
|
||||
server.send(200, "text/html", getHTML());
|
||||
server.send_P(200, PSTR("text/html"), PAGE_HTML);
|
||||
});
|
||||
|
||||
// Живое состояние. Опрашивается раз в 2 секунды, поэтому держим ответ коротким.
|
||||
server.on("/status", HTTP_GET, []() {
|
||||
String json = "{";
|
||||
json += "\"p1\":" + String(p1.stableState ? "1" : "0") + ",";
|
||||
json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ",";
|
||||
json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ",";
|
||||
json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ",";
|
||||
json += "\"rEmg\":" + String(relayEmergOn ? "1" : "0");
|
||||
json += "\"rEmg\":" + String(relayEmergOn ? "1" : "0") + ",";
|
||||
json += "\"rdy\":" + String(systemReady ? "1" : "0") + ",";
|
||||
// Формулировки приходят готовыми, а не собираются в браузере: та же
|
||||
// фраза уходит в webhook, и второй набор строк в JS неизбежно бы разошёлся.
|
||||
json += "\"k\":\"" + String(statusKind()) + "\",";
|
||||
json += "\"w\":\"" + String(statusWord()) + "\",";
|
||||
json += "\"s\":\"" + String(statusSub()) + "\"";
|
||||
json += "}";
|
||||
server.send(200, "application/json", json);
|
||||
});
|
||||
|
||||
// Настройки. Забираются один раз при загрузке страницы.
|
||||
// Webhook отдаётся только именем хоста: полный URL содержит секретный токен,
|
||||
// и выводить его на экран, который видно всем в помещении, незачем.
|
||||
server.on("/config", HTTP_GET, []() {
|
||||
String host;
|
||||
if (!parseWebhookHost(settings.webhookUrl, host)) host = "";
|
||||
|
||||
String json = "{";
|
||||
json += "\"device\":\"" + String(settings.deviceName) + "\",";
|
||||
json += "\"alarm\":" + String(settings.alarmDelaySec) + ",";
|
||||
json += "\"emerg\":" + String(settings.emergDelaySec) + ",";
|
||||
json += "\"host\":\"" + host + "\",";
|
||||
json += "\"fs\":" + String(settingsFsReady ? "1" : "0") + ",";
|
||||
json += "\"ip\":\"" + (WiFi.status() == WL_CONNECTED
|
||||
? WiFi.localIP().toString() : String("нет сети")) + "\"";
|
||||
json += "}";
|
||||
server.send(200, "application/json", json);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#pragma once
|
||||
|
||||
// ============================================================
|
||||
// ВЕБ-ПАНЕЛЬ
|
||||
// ============================================================
|
||||
// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё
|
||||
// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой
|
||||
// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча
|
||||
// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных.
|
||||
//
|
||||
// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ
|
||||
// («есть подача или нет») занимает верх страницы и читается с расстояния,
|
||||
// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная
|
||||
// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение,
|
||||
// система ещё справляется), красная «авария» только при остановке основных.
|
||||
//
|
||||
// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние
|
||||
// имена, и @import с Google Fonts там не грузится никогда. Характер задан
|
||||
// контрастом кеглей, а не гарнитурой.
|
||||
|
||||
static const char PAGE_HTML[] PROGMEM = R"=====(<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Насосная</title><link id="fav" rel="icon" href="data:,">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0e1216;--sf:#161b21;--ln:#232a32;--ink:#dde3e9;--dim:#8b97a3;
|
||||
--run:#35c46a;--warn:#f0a516;--flt:#e23c2e;--off:#2a323a}
|
||||
body{background:var(--bg);color:var(--ink);min-height:100vh;padding:16px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
|
||||
font-size:15px;line-height:1.4;display:flex;justify-content:center}
|
||||
.wrap{width:100%;max-width:520px;display:flex;flex-direction:column;gap:10px}
|
||||
.top{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
|
||||
font-size:13px;color:var(--dim)}
|
||||
.top b{font-weight:600;color:var(--ink);font-size:14px}
|
||||
.ip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||
.st{border-radius:14px;padding:20px;border:1px solid var(--ln);background:var(--sf)}
|
||||
.st b{display:block;font-size:clamp(28px,8.5vw,44px);line-height:1.05;
|
||||
letter-spacing:-.02em;font-weight:800}
|
||||
.st span{display:block;margin-top:6px;font-size:14px;color:var(--dim)}
|
||||
.st.ok{background:rgba(53,196,106,.10);border-color:rgba(53,196,106,.35)}
|
||||
.st.ok b{color:var(--run)}
|
||||
.st.warn{background:rgba(240,165,22,.10);border-color:rgba(240,165,22,.4)}
|
||||
.st.warn b{color:var(--warn)}
|
||||
.st.flt{background:rgba(226,60,46,.12);border-color:rgba(226,60,46,.45);
|
||||
animation:beat 1.6s ease-in-out infinite}
|
||||
.st.flt b{color:var(--flt)}
|
||||
@keyframes beat{50%{background:rgba(226,60,46,.26)}}
|
||||
@media(prefers-reduced-motion:reduce){.st.flt{animation:none}}
|
||||
.row{display:grid;gap:8px}
|
||||
.r3{grid-template-columns:repeat(3,1fr)}
|
||||
.r2{grid-template-columns:repeat(2,1fr)}
|
||||
.c{background:var(--sf);border:1px solid var(--ln);border-radius:12px;padding:12px}
|
||||
.c i{display:block;width:13px;height:13px;border-radius:50%;background:var(--off);
|
||||
margin-bottom:10px;transition:background .2s}
|
||||
.c.on i{background:var(--run);box-shadow:0 0 10px rgba(53,196,106,.7)}
|
||||
.c.on.w i{background:var(--warn);box-shadow:0 0 10px rgba(240,165,22,.7)}
|
||||
.c.on.f i{background:var(--flt);box-shadow:0 0 10px rgba(226,60,46,.7)}
|
||||
.c em{font-style:normal;display:block;font-size:11px;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--dim)}
|
||||
.c b{display:block;margin-top:3px;font-size:14px;font-weight:600}
|
||||
details{background:var(--sf);border:1px solid var(--ln);border-radius:12px}
|
||||
summary{padding:12px 14px;cursor:pointer;font-size:14px;font-weight:600;
|
||||
list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
summary::-webkit-details-marker{display:none}
|
||||
summary::after{content:"▾";color:var(--dim)}
|
||||
details[open] summary::after{content:"▴"}
|
||||
.body{padding:0 14px 14px;display:flex;flex-direction:column;gap:12px}
|
||||
label{display:block;font-size:12px;color:var(--dim);margin-bottom:5px}
|
||||
input{width:100%;padding:9px 10px;background:#0f1418;border:1px solid var(--ln);
|
||||
border-radius:8px;color:var(--ink);font:inherit;font-size:14px}
|
||||
input:focus{outline:2px solid rgba(53,196,106,.5);outline-offset:1px}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.hint{font-size:12px;color:var(--dim)}
|
||||
button{width:100%;padding:11px;border-radius:8px;border:1px solid;font:inherit;
|
||||
font-size:14px;font-weight:600;cursor:pointer}
|
||||
.save{background:rgba(53,196,106,.14);border-color:rgba(53,196,106,.45);color:var(--run)}
|
||||
.wifi{background:transparent;border-color:var(--ln);color:var(--dim);margin-top:2px}
|
||||
.warnbox{background:rgba(226,60,46,.12);border:1px solid rgba(226,60,46,.4);
|
||||
border-radius:8px;padding:10px;font-size:13px;color:#f2a49c}
|
||||
.hide{display:none}
|
||||
</style></head><body><div class="wrap">
|
||||
|
||||
<div class="top"><b id="dev">—</b><span class="ip" id="ip"></span></div>
|
||||
|
||||
<div class="st init" id="st"><b id="word">Связь…</b><span id="sub">получаем состояние прибора</span></div>
|
||||
|
||||
<div class="row r3">
|
||||
<div class="c" id="l1"><i></i><em>Насос 1</em><b>—</b></div>
|
||||
<div class="c" id="l2"><i></i><em>Насос 2</em><b>—</b></div>
|
||||
<div class="c w" id="lE"><i></i><em>Резерв</em><b>—</b></div>
|
||||
</div>
|
||||
|
||||
<div class="row r2">
|
||||
<div class="c f" id="o1"><i></i><em>Выход «Авария»</em><b>—</b></div>
|
||||
<div class="c w" id="o2"><i></i><em>Выход «Резерв»</em><b>—</b></div>
|
||||
</div>
|
||||
|
||||
<details><summary>Настройки</summary><div class="body">
|
||||
<div class="warnbox hide" id="fsw">Память недоступна — настройки не сохранятся до перезагрузки.</div>
|
||||
<form method="POST" action="/set_config">
|
||||
<div><label for="u">Адрес для уведомлений</label>
|
||||
<input id="u" name="url" type="text" placeholder="оставьте пустым, чтобы не менять">
|
||||
<div class="hint" id="uh" style="margin-top:5px"></div></div>
|
||||
<div style="margin-top:12px"><label for="d">Имя прибора в уведомлениях</label>
|
||||
<input id="d" name="device" maxlength="31">
|
||||
<div class="hint" style="margin-top:5px">Латиница, цифры, дефис и подчёркивание</div></div>
|
||||
<div style="margin-top:12px"><label>Задержка сигнала, секунд</label>
|
||||
<div class="two">
|
||||
<div><input id="a" name="alarm_delay" type="number" min="0" max="3600" aria-label="Задержка аварии">
|
||||
<div class="hint" style="margin-top:5px">аварии</div></div>
|
||||
<div><input id="e" name="emerg_delay" type="number" min="0" max="3600" aria-label="Задержка резерва">
|
||||
<div class="hint" style="margin-top:5px">резерва</div></div></div></div>
|
||||
<button class="save" type="submit" style="margin-top:14px">Сохранить</button>
|
||||
</form>
|
||||
<form method="POST" action="/reset_wifi"><button class="wifi" type="submit">Сменить сеть Wi-Fi</button></form>
|
||||
</div></details>
|
||||
|
||||
</div><script>
|
||||
var F={ok:"#35c46a",warn:"#f0a516",flt:"#e23c2e",init:"#8b97a3"};
|
||||
function lamp(id,on,txt){var e=document.getElementById(id);
|
||||
e.classList.toggle("on",!!on);e.querySelector("b").textContent=txt}
|
||||
function fav(c){document.getElementById("fav").href="data:image/svg+xml,"+
|
||||
encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='13' fill='"+c+"'/></svg>")}
|
||||
var dev="";
|
||||
function draw(d){
|
||||
lamp("l1",d.p1,d.p1?"работает":"стоит");
|
||||
lamp("l2",d.p2,d.p2?"работает":"стоит");
|
||||
lamp("lE",d.pE,d.pE?"работает":"ожидание");
|
||||
lamp("o1",d.rMain,d.rMain?"замкнут":"разомкнут");
|
||||
lamp("o2",d.rEmg,d.rEmg?"замкнут":"разомкнут");
|
||||
document.getElementById("st").className="st "+d.k;
|
||||
document.getElementById("word").textContent=d.w;
|
||||
document.getElementById("sub").textContent=d.s;
|
||||
document.title=d.w+(dev?" · "+dev:"");
|
||||
fav(F[d.k])}
|
||||
function poll(){fetch("/status").then(function(r){return r.json()}).then(draw).catch(function(){})}
|
||||
fetch("/config").then(function(r){return r.json()}).then(function(c){
|
||||
dev=c.device;document.getElementById("dev").textContent=c.device;
|
||||
document.getElementById("ip").textContent=c.ip;
|
||||
document.getElementById("d").value=c.device;
|
||||
document.getElementById("a").value=c.alarm;
|
||||
document.getElementById("e").value=c.emerg;
|
||||
document.getElementById("uh").textContent=c.host?"Сейчас: "+c.host:"Не задан — уведомления отключены";
|
||||
if(!c.fs)document.getElementById("fsw").classList.remove("hide")}).catch(function(){});
|
||||
poll();setInterval(poll,2000);
|
||||
</script></body></html>)====="
|
||||
Reference in New Issue
Block a user