From cb151d2e304df0146d7047426e83aa922d59419a Mon Sep 17 00:00:00 2001 From: Vitali Date: Sat, 8 Aug 2026 00:43:34 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D0=B2=D0=B5=D0=B1?= =?UTF-8?q?-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B8=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=20OLED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель как световое табло, а не дашборд: главный ответ занимает верх страницы и читается с расстояния. Прежде самым крупным элементом были часы браузера - единственное, что не несло информации о приборе, - а ответ "есть подача или нет" был набран 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 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 127 +++-- pump_controller_8_2_OLED_DONE/README.md | 127 +++-- .../pump_controller_8_2_OLED_DONE.ino | 462 +++++++++--------- 3 files changed, 384 insertions(+), 332 deletions(-) diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index 82701b7..1fe28d1 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -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://` в любом браузере в той же сети. -На странице: +Панель устроена как световое табло: **сверху крупно главный ответ**, ниже — +лампы датчиков и выходов, в самом низу свёрнутые настройки. -* три карточки насосов с индикаторами; -* панель состояния системы (`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`. -**На новом приборе адрес уведомлений не задан** — панель покажет -`не задан - уведомления отключены`, сообщения отправляться не будут, всё -остальное работает. +**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан — +уведомления отключены», сообщения отправляться не будут, всё остальное работает. --- @@ -219,7 +244,7 @@ STATUS: OK `Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3). **Если надо перевести прибор в другую сеть, а текущая работает** — нажмите -**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку +**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку доступа. **Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите @@ -238,15 +263,15 @@ STATUS: OK | Событие | Задержка | |---|---| | Фиксация нового состояния датчика | 3 секунды (не настраивается) | -| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с | -| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с | +| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с | +| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с | | Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** | | Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды | -Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на +Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на реле и на уведомление** — отдельно задержать только уведомление нельзя. -Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды +Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды антидребезга. --- @@ -266,7 +291,7 @@ STATUS: OK | Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки | | Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет | | Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` | -| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется | +| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется | | Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание | Подробный лог работы выводится в USB-порт: скорость **115200 бод** diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index e586ef0..cd3ea0e 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -395,46 +395,81 @@ 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} +/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.6 Доступ + +Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить адрес +уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации. +Контроллер рассчитан на изолированный технологический сегмент. --- @@ -442,28 +477,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 кириллицы не содержит, а +тащить её во флеш ради четырёх слов не стоит. + +Состояние обоих реле теперь выводится — освободились две строки после +объединения датчиков в одну. --- diff --git a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino index 6f6ab32..7417c93 100644 --- a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino +++ b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino @@ -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); - // --- Заголовок --- + // --- Главный ответ, кегль 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"); + + // При аварии строка мигает инверсией каждые 500 мс + bool blink = alarm && ((millis() / 500) % 2 == 0); + if (blink) { + display.fillRect(0, 0, SCREEN_WIDTH, 18, SSD1306_WHITE); + display.setTextColor(SSD1306_BLACK); + } + display.setTextSize(2); + display.setCursor(0, 1); + display.print(word); + display.setTextColor(SSD1306_WHITE); display.setTextSize(1); - display.setCursor(0, 0); - display.print(F(" PUMP CONTROLLER v8")); - // --- Разделитель --- - display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE); + display.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE); - // --- Состояния насосов --- - display.setCursor(0, 14); - display.print(F("PUMP 1 : ")); - display.print(p1.stableState ? F("RUNNING") : F("STOPPED")); + // --- Датчики: три поля в одной строке вместо трёх строк --- + 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, 24); - display.print(F("PUMP 2 : ")); - display.print(p2.stableState ? F("RUNNING") : F("STOPPED")); + // --- Выходы на реле --- + 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.setCursor(0, 34); - display.print(F("BACKUP : ")); - display.print(pE.stableState ? F("ACTIVE ") : F("STANDBY")); + display.drawFastHLine(0, 49, SCREEN_WIDTH, SSD1306_WHITE); - // --- Разделитель --- - 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.print(F("!! ALARM: NO PUMPS !!")); - display.setTextColor(SSD1306_WHITE); - } else if (emergency) { - display.print(F("WARN: BACKUP RUNNING")); - } else { - display.print(F("STATUS: OK")); - } + // --- Адрес в сети --- + display.setCursor(0, 54); + if (WiFi.status() == WL_CONNECTED) display.print(WiFi.localIP().toString()); + else display.print(F("no network")); display.display(); } @@ -534,190 +532,157 @@ void updatePumpState(Pump &p) { } // ============================================================ -// HTML интерфейс (без изменений логики, только IP-адрес актуален) +// ВЕБ-ПАНЕЛЬ // ============================================================ -String getHTML() { - bool isAlarm = (!p1.stableState && !p2.stableState); +// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё +// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой +// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча +// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных. +// +// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ +// («есть подача или нет») занимает верх страницы и читается с расстояния, +// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная +// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение, +// система ещё справляется), красная «авария» только при остановке основных. +// +// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние +// имена, и @import с Google Fonts там не грузится никогда. Характер задан +// контрастом кеглей, а не гарнитурой. - String html = F(""); - html += ""; - html += F("" - "Мониторинг Насосов" - "
"); +static const char PAGE_HTML[] PROGMEM = R"=====( + +Насосная +
- html += F("
" - "
SYS · PUMP_CTRL
" - "
Мониторинг
Насосов
" - "
LIVE · AUTO
" - "
--:--:--
" - "
"); +
- html += "
"; +
Связь…получаем состояние прибора
- // Насос 1 - html += "
"; - html += F("
Насос
" - "" - "" - "" - "" - "" - "" - "" - ""); - html += "
Основной 1
" + String(p1.stableState ? "Работает" : "Стоит") + "
"; +
+
Насос 1
+
Насос 2
+
Резерв
+
- // Насос 2 - html += "
"; - html += F("
Насос
" - "" - "" - "" - "" - "" - "" - "" - ""); - html += "
Основной 2
" + String(p2.stableState ? "Работает" : "Стоит") + "
"; +
+
Выход «Авария»
+
Выход «Резерв»
+
- // Резерв - html += "
"; - html += F("
Резерв
" - "" - "" - "" - "" - ""); - html += "
Резерв
" + String(pE.stableState ? "Активен" : "Ожидание") + "
"; +
Настройки
+
Память недоступна — настройки не сохранятся до перезагрузки.
+
+
+ +
+
+ +
Латиница, цифры, дефис и подчёркивание
+
+
+
+
аварии
+
+
резерва
+ +
+
+
- html += "
"; - - if (isAlarm) { - html += "
CRITICAL · 0xFF
Нет подачи!
"; - } else { - html += "
SYS_OK · 0x00
Система: норма
"; - } - - html += "
Управление
"; - // --- Состояние реле --- - html += "
"; - html += "
"; - html += F("
Реле «Авария»
"); - html += "
"; - html += "
" + - String(relayMainOn ? "Включено" : "Выключено") + "
"; - - html += "
"; - html += F("
Реле «Резерв»
"); - html += "
"; - html += "
" + - String(relayEmergOn ? "Включено" : "Выключено") + "
"; - html += "
"; - - // --- Настройки --- - html += F("
Настройки
"); - if (!settingsFsReady) { - html += F("
" - "Файловая система недоступна — настройки не переживут перезагрузку!
"); - } - html += "
Webhook: " + - String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "
"; - html += F("
"); - html += F("
Webhook URL
" - "
"); - html += "
Имя устройства (A-Z a-z 0-9 _ -)
" - "
"; - html += "
Выдержка аварии, с
" - "
"; - html += "
Выдержка резерва, с
" - "
"; - html += F("
" - "
"); - - if (pE.stableState) { - html += "
WARN · BACKUP
Работает резерв!
"; - } - - html += F("
"); - - return html; -} +
)====="; // ============================================================ // SETUP @@ -793,16 +758,39 @@ 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"); + 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); });