From cb151d2e304df0146d7047426e83aa922d59419a Mon Sep 17 00:00:00 2001 From: Vitali Date: Sat, 8 Aug 2026 00:43:34 +0700 Subject: [PATCH 1/3] =?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); }); From 6240862c27a86681f3a061698d8f077e3f120372 Mon Sep 17 00:00:00 2001 From: Vitali Date: Sat, 8 Aug 2026 01:08:17 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(build):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20HTML=20=D0=B2=20web=5Fpage.h=20-=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20arduino-cli=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Генератор прототипов Arduino не понимает сырые строковые литералы: внутри JS он нашёл "function draw(d){" и "function poll(){", принял их за определения функций C++ и вставил для них прототипы в начало файла. Сборка падала с "error: 'function' does not name a type", а директивы #line показывали ошибку на строках внутри HTML. Заголовочные файлы препроцессор не сканирует, поэтому страница переехала в web_page.h. Тот же приём используется в соседнем проекте. Ошибка не всплывала раньше потому, что я проверял скетч через g++ -fsyntax-only по .ino: так файл компилируется как обычный .cpp и препроцессор Arduino не участвует. Проверять сборку нужно arduino-cli, это записано в README 11.1. Прошито и проверено на железе (COM7, LOLIN D1 ESP-WROOM-02, 2MB FS:64KB): размеры флеша совпадают, LittleFS монтируется, ранее заданные через панель настройки пережили перепрошивку, Wi-Fi поднимается, датчики читаются. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/README.md | 41 ++++- .../pump_controller_8_2_OLED_DONE.ino | 157 +----------------- pump_controller_8_2_OLED_DONE/web_page.h | 154 +++++++++++++++++ 3 files changed, 198 insertions(+), 154 deletions(-) create mode 100644 pump_controller_8_2_OLED_DONE/web_page.h diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index cd3ea0e..71de6c0 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -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/` | Согласованные проектные решения по крупным изменениям | @@ -465,6 +466,21 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и 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, подменить адрес @@ -556,14 +572,29 @@ ALARM:ON BACKUP:ON <- состояние выходов --- -## 11. Сборка +## 11. Сборка и прошивка + +Плата в IDE: **LOLIN(WEMOS) D1 ESP-WROOM-02**, а не `D1 R2 & mini`. У WROOM-02 +модуль на 2 МБ, и только у этой платы в списке есть нужные варианты `2MB (...)`. +`Flash Size` по умолчанию для неё — `2MB (FS:64KB OTA:~992KB)`, он и используется. + +`arduino-cli` отдельно ставить не нужно, он идёт внутри Arduino IDE: ```bash -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` не показывает, +на реальной сборке останавливает всё. 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 7417c93..ba581bc 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 @@ -534,155 +534,14 @@ void updatePumpState(Pump &p) { // ============================================================ // ВЕБ-ПАНЕЛЬ // ============================================================ -// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё -// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой -// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча -// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных. -// -// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ -// («есть подача или нет») занимает верх страницы и читается с расстояния, -// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная -// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение, -// система ещё справляется), красная «авария» только при остановке основных. -// -// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние -// имена, и @import с Google Fonts там не грузится никогда. Характер задан -// контрастом кеглей, а не гарнитурой. - -static const char PAGE_HTML[] PROGMEM = R"=====( - -Насосная -
- -
- -
Связь…получаем состояние прибора
- -
-
Насос 1
-
Насос 2
-
Резерв
-
- -
-
Выход «Авария»
-
Выход «Резерв»
-
- -
Настройки
-
Память недоступна — настройки не сохранятся до перезагрузки.
-
-
- -
-
- -
Латиница, цифры, дефис и подчёркивание
-
-
-
-
аварии
-
-
резерва
- -
-
-
- -
)====="; +// Разметка вынесена в web_page.h намеренно, а не для красоты: генератор +// прототипов Arduino не понимает сырые строковые литералы и принимает +// "function draw(d){" внутри JS за определение функции C++, после чего +// вставляет для него прототип и сборка падает. Заголовочные файлы он не +// сканирует. Обычный g++ такую ошибку не показывает — она видна только +// при сборке через arduino-cli или IDE. +#include "web_page.h" +; // ============================================================ // SETUP diff --git a/pump_controller_8_2_OLED_DONE/web_page.h b/pump_controller_8_2_OLED_DONE/web_page.h new file mode 100644 index 0000000..41d5995 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/web_page.h @@ -0,0 +1,154 @@ +#pragma once + +// ============================================================ +// ВЕБ-ПАНЕЛЬ +// ============================================================ +// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё +// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой +// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча +// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных. +// +// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ +// («есть подача или нет») занимает верх страницы и читается с расстояния, +// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная +// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение, +// система ещё справляется), красная «авария» только при остановке основных. +// +// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние +// имена, и @import с Google Fonts там не грузится никогда. Характер задан +// контрастом кеглей, а не гарнитурой. + +static const char PAGE_HTML[] PROGMEM = R"=====( + +Насосная +
+ +
+ +
Связь…получаем состояние прибора
+ +
+
Насос 1
+
Насос 2
+
Резерв
+
+ +
+
Выход «Авария»
+
Выход «Резерв»
+
+ +
Настройки
+
Память недоступна — настройки не сохранятся до перезагрузки.
+
+
+ +
+
+ +
Латиница, цифры, дефис и подчёркивание
+
+
+
+
аварии
+
+
резерва
+ +
+
+
+ +
)=====" From 0c06ba1032cbf77c22e77e8044107b16da127842 Mon Sep 17 00:00:00 2001 From: Vitali Date: Sat, 8 Aug 2026 01:33:48 +0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(webhook):=20=D1=81=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=20status=20=D1=87=D0=B5=D0=BB=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D1=83=D1=8E=20=D1=84=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D1=83=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В теле уведомления status теперь содержит ту же формулировку, что видит оператор в панели: «Насосы стоят, подачу держит резерв». Прежние машиночитаемые значения переехали в новое поле state (ALARM / OK / STARTED / STOPPED), чтобы сценарий на сервере ветвился по нему, а не разбирал русский текст. ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ: если сценарий в n8n сравнивал status с "ALARM", условие надо переключить на state. Формулировки собраны в одном месте прошивки (statusKind / statusWord / statusSub / statusPhrase) и оттуда попадают и в webhook, и в панель через новые поля k, w, s в /status. Раньше та же логика была продублирована в JS панели; два набора фраз разошлись бы при первой правке. Побочно JS сократился почти вдвое. Прошито и проверено на приборе: /status отдаёт {"k":"ok","w":"Подача есть","s":"работает насос 1"}, /config по-прежнему отдаёт только имя хоста без токена. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 16 +++-- pump_controller_8_2_OLED_DONE/README.md | 38 +++++++++--- .../pump_controller_8_2_OLED_DONE.ino | 60 +++++++++++++++++-- pump_controller_8_2_OLED_DONE/web_page.h | 18 ++---- 4 files changed, 100 insertions(+), 32 deletions(-) diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index 1fe28d1..248ec71 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -217,12 +217,18 @@ ALARM:OFF BACKUP:OFF При изменении ситуации прибор отправляет сообщение на настроенный адрес: -| Событие | Что означает | +В сообщении приходит **та же фраза, что показывает панель** — читать можно +без расшифровки кодов: + +| Ситуация | Текст в сообщении | |---|---| -| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят | -| `MAIN PUMPS` / `OK` | Работа восстановлена | -| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос | -| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился | +| Оба основных стоят, резерв качает | «Насосы стоят, подачу держит резерв» | +| Оба основных стоят, резерва нет | «Насосы стоят, подачи нет» | +| Работает резерв, основные в порядке | «Работает резерв, основные насосы в работе» | +| Работа восстановлена | «Подача есть, работает насос 1» | + +Рядом с текстом передаётся служебное поле для автоматики: `MAIN PUMPS` или +`EMERGENCY PUMP` и `ALARM` / `OK` / `STARTED` / `STOPPED`. Уведомления отправляются один раз на каждое изменение, а не повторяются. Если в момент события связи не было — уведомление **не будет** отправлено diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index 71de6c0..789c1d2 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -214,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()` — сертификат сервера **не проверяется**. Достаточно для отправки в доверенную @@ -417,7 +436,8 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и Ответы: ```json -/status {"p1":1,"p2":0,"pE":0,"rMain":0,"rEmg":0,"rdy":1} +/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"} ``` 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 ba581bc..b780ed1 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 @@ -410,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; @@ -467,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); @@ -531,6 +536,46 @@ void updatePumpState(Pump &p) { p.lastReading = currentReading; } +// ============================================================ +// Словесное описание состояния +// ============================================================ +// Единственное место, где живёт формулировка. Её берут и веб-панель (через +// /status), и тело webhook — иначе два набора фраз разошлись бы при первой +// же правке. Фразы не содержат кавычек и обратных слэшей, поэтому безопасно +// вставляются в JSON без экранирования. + +// Вид состояния: им панель выбирает цвет плашки. +static const char* statusKind() { + if (!systemReady) return "init"; + if (!p1.stableState && !p2.stableState) return "flt"; + if (pE.stableState) return "warn"; + return "ok"; +} + +// Главное слово — крупная строка в панели и начало фразы в уведомлении. +static const __FlashStringHelper* statusWord() { + if (!systemReady) return F("Запуск"); + if (!p1.stableState && !p2.stableState) return F("Насосы стоят"); + if (pE.stableState) return F("Работает резерв"); + return F("Подача есть"); +} + +// Уточнение — вторая строка в панели и хвост фразы в уведомлении. +static const __FlashStringHelper* statusSub() { + if (!systemReady) return F("идёт проверка датчиков"); + if (!p1.stableState && !p2.stableState) { + return pE.stableState ? F("подачу держит резерв") : F("подачи нет"); + } + if (pE.stableState) return F("основные насосы в работе"); + if (p1.stableState && p2.stableState) return F("работают оба насоса"); + return p1.stableState ? F("работает насос 1") : F("работает насос 2"); +} + +// Готовая фраза целиком: «Насосы стоят, подачу держит резерв». +static String statusPhrase() { + return String(statusWord()) + ", " + String(statusSub()); +} + // ============================================================ // ВЕБ-ПАНЕЛЬ // ============================================================ @@ -630,7 +675,12 @@ void setup() { json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ","; json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ","; json += "\"rEmg\":" + String(relayEmergOn ? "1" : "0") + ","; - json += "\"rdy\":" + String(systemReady ? "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); }); diff --git a/pump_controller_8_2_OLED_DONE/web_page.h b/pump_controller_8_2_OLED_DONE/web_page.h index 41d5995..5533781 100644 --- a/pump_controller_8_2_OLED_DONE/web_page.h +++ b/pump_controller_8_2_OLED_DONE/web_page.h @@ -128,19 +128,11 @@ lamp("l2",d.p2,d.p2?"работает":"стоит"); lamp("lE",d.pE,d.pE?"работает":"ожидание"); lamp("o1",d.rMain,d.rMain?"замкнут":"разомкнут"); lamp("o2",d.rEmg,d.rEmg?"замкнут":"разомкнут"); -var k,w,s; -if(!d.rdy){k="init";w="Запуск";s="идёт проверка датчиков"} -else if(!d.p1&&!d.p2){k="flt";w="Насосы стоят"; -s=d.pE?"подачу держит резерв":"подачи нет"} -else if(d.pE){k="warn";w="Работает резерв";s="основные насосы в работе"} -else{k="ok";w="Подача есть"; -s=d.p1&&d.p2?"работают оба насоса":(d.p1?"работает насос 1":"работает насос 2")} -var st=document.getElementById("st"); -st.className="st "+k; -document.getElementById("word").textContent=w; -document.getElementById("sub").textContent=s; -document.title=w+(dev?" · "+dev:""); -fav(F[k])} +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;