Compare commits

..
18 Commits
Author SHA1 Message Date
oskarvitaliiandClaude Opus 5 ed4dad0a52 docs: добавить снимок веб-панели
Снимок с работающего прибора: плашка «Подача есть», лампы датчиков и
выходов, раскрытый блок настроек. Вставлен в README (раздел 7) и в
MANUAL (раздел 5).

На снимке нет чувствительных данных: адрес уведомлений показан только
именем хоста и это заглушка, IP приватный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:30:25 +07:00
oskarvitaliiandClaude Opus 5 fde9ee799a docs: исправить утверждение о поведении реле при загрузке
На собранном приборе оба реле кратковременно моргают при перезагрузке.
README и MANUAL утверждали обратное - что реле при загрузке молчат.
Это было предсказание из рассуждения об уровнях на пинах, а не
наблюдение, и оно не подтвердилось. Формулировки исправлены.

Вероятный механизм описан честно, как вероятный: оба выхода сидят на
служебных пинах (GPIO2 это UART1 TX, GPIO0 - пин выбора режима загрузки),
и пока setup() не выполнился, уровнем управляет не прошивка. Осциллографом
не проверяли, о чём в тексте сказано.

Само решение остаётся верным: альтернативный D8 подтянут к LOW и держал бы
реле уверенно включённым всю загрузку вместо короткого моргания.

Пользователь признал поведение некритичным, прошивка не менялась. В
документации указано, чем лечится, если однажды станет мешать.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:51:30 +07:00
oskarvitaliiandClaude Opus 5 0c06ba1032 feat(webhook): слать в status человеческую фразу вместо кода
В теле уведомления status теперь содержит ту же формулировку, что видит
оператор в панели: «Насосы стоят, подачу держит резерв». Прежние
машиночитаемые значения переехали в новое поле state (ALARM / OK /
STARTED / STOPPED), чтобы сценарий на сервере ветвился по нему, а не
разбирал русский текст.

ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ: если сценарий в n8n сравнивал status с "ALARM",
условие надо переключить на state.

Формулировки собраны в одном месте прошивки (statusKind / statusWord /
statusSub / statusPhrase) и оттуда попадают и в webhook, и в панель через
новые поля k, w, s в /status. Раньше та же логика была продублирована в
JS панели; два набора фраз разошлись бы при первой правке. Побочно JS
сократился почти вдвое.

Прошито и проверено на приборе: /status отдаёт
{"k":"ok","w":"Подача есть","s":"работает насос 1"}, /config по-прежнему
отдаёт только имя хоста без токена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:33:48 +07:00
oskarvitaliiandClaude Opus 5 6240862c27 fix(build): вынести HTML в web_page.h - сборка через arduino-cli падала
Генератор прототипов Arduino не понимает сырые строковые литералы: внутри
JS он нашёл "function draw(d){" и "function poll(){", принял их за
определения функций C++ и вставил для них прототипы в начало файла.
Сборка падала с "error: 'function' does not name a type", а директивы
#line показывали ошибку на строках внутри HTML.

Заголовочные файлы препроцессор не сканирует, поэтому страница переехала
в web_page.h. Тот же приём используется в соседнем проекте.

Ошибка не всплывала раньше потому, что я проверял скетч через
g++ -fsyntax-only по .ino: так файл компилируется как обычный .cpp и
препроцессор Arduino не участвует. Проверять сборку нужно arduino-cli,
это записано в README 11.1.

Прошито и проверено на железе (COM7, LOLIN D1 ESP-WROOM-02, 2MB FS:64KB):
размеры флеша совпадают, LittleFS монтируется, ранее заданные через панель
настройки пережили перепрошивку, Wi-Fi поднимается, датчики читаются.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:08:17 +07:00
oskarvitaliiandClaude Opus 5 cb151d2e30 feat(ui): переработать веб-панель и макет OLED
Панель как световое табло, а не дашборд: главный ответ занимает верх
страницы и читается с расстояния. Прежде самым крупным элементом были
часы браузера - единственное, что не несло информации о приборе, - а
ответ "есть подача или нет" был набран 11 кеглем в панели размером с
кнопку. Норма и авария выглядели почти одинаково.

Исправлены три содержательные ошибки:

- лампа выхода "Авария" горела ЗЕЛЁНЫМ при срабатывании: стиль
  "включено" был общим для всех ламп, и сработавшая сигнализация
  подсвечивалась цветом "всё хорошо";
- страница делала location.reload() при любом изменении состояния,
  стирая недописанный адрес в форме вместе с прокруткой и фокусом;
- CSS подключал шрифты с Google Fonts, тогда как сеть на объекте не
  разрешает внешние имена - нарисованный макет там не появлялся никогда.

Страница стала статической и уехала во флеш (PAGE_HTML PROGMEM, ~8 КБ,
отдаётся через send_P). Раньше HTML собирался строкой в куче на каждый
запрос: ~14 КБ при ~40 КБ свободной кучи. Состояние подставляет JS,
забирая /status и новый /config. В /status добавлено поле rdy.

/config отдаёт только имя хоста webhook: полный URL содержит секретный
токен, и выводить его на экран, видимый всем в помещении, незачем.

Цвета по конвенции сигнальных ламп: резерв теперь ЖЁЛТЫЙ, а не красный -
работающий резерв это предупреждение, система ещё справляется. Красный
оставлен для остановки основных. Убраны декоративные коды SYS_OK / 0xFF,
не кодировавшие ничего, и тройное дублирование состояния резерва.
Настройки свёрнуты в details.

OLED: верхняя строка отдана статусу кеглем 2 - прежде её постоянно
занимал заголовок "PUMP CONTROLLER v8", который никогда не менялся.
Три строки датчиков сжаты в одну, освободившееся место отдано состоянию
обоих реле. Текст остаётся латиницей: встроенный шрифт Adafruit GFX
кириллицы не содержит.

Проверено: сборка -Wall -Wextra без предупреждений; все три состояния
панели просмотрены в браузере на 375 и 800 px. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:43:34 +07:00
oskarvitaliiandClaude Opus 5 42cbb82492 chore: allowlist .gitignore, рабочий каталог D:\Soft\Arduino как корень репо
Локально все скетчи лежат вперемешку в одной папке: рабочие проекты,
старые версии, эксперименты, архивы и драйверы. Чтобы этот каталог мог
служить рабочим деревом репозитория, .gitignore инвертирован — по
умолчанию игнорируется всё, проекты добавляются точечно строкой
"!/ИмяПапки/".

Прежние правила (артефакты сборки, мусор ОС, secrets.h) сохранены и
перенесены ниже белого списка: более поздний шаблон побеждает, поэтому
они продолжают действовать внутри отслеживаемых папок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:13:58 +07:00
oskarvitaliiandClaude Opus 5 2aa7e20fd4 docs: add pump_controller_8_2_OLED_DONE to the projects index
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:56:50 +07:00
oskarvitalii e91d13b8eb Добавить проект pump_controller_8_2_OLED_DONE
Контроллер насосной станции на Wemos D1 mini: мониторинг двух основных
и резервного насоса, два реле аварий, OLED, веб-панель с настройками,
уведомления на HTTPS-webhook.

История проекта влита целиком, в сообщениях коммитов разобраны причины
принятых решений: выбор пинов D3/D4 под реле active-LOW, ограничение
блокировки loop() при отправке webhook, перенос настроек в LittleFS и
найденная причина несохранения настроек (чип 2 МБ при настройке IDE 4 МБ).
2026-08-07 22:56:09 +07:00
oskarvitaliiandClaude Opus 5 3f21b44a65 Уточнить в коде причину отказа от EEPROM
Комментарий утверждал, что запись в EEPROM на этой плате проваливается,
что читается как «EEPROM сломана». На деле причина была в неверном
Flash Size, и с верной настройкой работали бы оба механизма. Формулировка
исправлена, чтобы следующий читатель не пошёл менять хранилище вместо
проверки размера чипа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 20:05:42 +07:00
oskarvitaliiandClaude Opus 5 703c4d46c6 Зафиксировать проверенную на железе раскладку 2MB (FS:64KB)
Прошивка запущена на целевой плате: LittleFS монтируется и отдаёт
45056 Б, файл настроек читается при старте. В документации указан
фактически использованный вариант Flash Size вместо предполагавшегося
FS:128KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 20:04:42 +07:00
oskarvitaliiandClaude Opus 5 963209dcc9 Найдена причина: чип 2 МБ при настройке IDE 4 МБ
Плата имеет физически 2 МБ флеша, в IDE было выбрано 4MB (FS:2MB).
При этой раскладке сектор EEPROM лежит по смещению 0x3FB000, а начало
LittleFS — по 0x400000, тогда как чип заканчивается на 0x200000. Обе
области вне микросхемы, поэтому spi_flash_erase_sector() проваливался
и EEPROM.commit() возвращал false.

Лечится настройкой Flash Size = 2MB (FS:128KB OTA:~960KB), а не кодом:
любая раскладка 2MB кладёт файловую систему ниже 0x1FB000, а EEPROM в
сектор 511. С верной настройкой работали бы оба механизма хранения.

Ранее записанная в README гипотеза про «щель между FS и областью SDK»
была неверной и заменена подтверждённым разбором. Переход на LittleFS
остаётся оправданным по своим качествам, но проблему решает настройка
IDE — в документации это разделено явно, чтобы не ввести в заблуждение
при похожем симптоме на другой плате.

Документация исправлена: плата 2 МБ, а не 4 МБ; требование совпадения
выбранного размера с физическим; предупреждение о потере учётных данных
Wi-Fi при смене раскладки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:51:38 +07:00
oskarvitaliiandClaude Opus 5 4c0f9672f3 Перенести настройки из EEPROM в LittleFS
EEPROM.commit() на целевой плате возвращает false — падает сам вызов SDK.
Проверкой исходников исключены ошибка в библиотеке, перекрытие адресов и
стирание сектора со стороны WiFiManager. Физический размер флеша тоже ни
при чём: LittleFS на этом же чипе работает, что подтверждено соседним
проектом на такой же плате.

Наиболее вероятный механизм: при раскладке 4MB/FS:2MB сектор EEPROM (1019,
0x3FB000) попадает в щель между концом ФС (0x3FA000) и служебной областью
SDK (0x3FC000) и оказывается защищён от стирания. Доказать не удалось,
поэтому в документации гипотеза помечена как гипотеза. Переход на LittleFS
выбран не как обход симптома, а как переезд на подтверждённо работающий
на этом железе механизм.

Настройки теперь в /settings.json (ArduinoJson v7). Загрузка
отказоустойчива: отсутствующий или битый ключ оставляет значение по
умолчанию, поэтому файл от прошлой версии прошивки не ломается при
добавлении полей. Имена ключей вынесены в константы KEY_*, используемые и
при чтении, и при записи.

Молчаливой потери настроек больше нет: при недоступной ФС взводится
settingsFsReady = false, и веб-панель показывает предупреждение, что
настройки не переживут перезагрузку. В лог при старте выводится геометрия
ФС и предупреждение при выборе FS:none.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра
3.1.2 — предупреждений нет. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:41:05 +07:00
oskarvitaliiandClaude Opus 5 ca02a48684 Диагностика: печатать сектор EEPROM и границы FS против физического флеша
commit() возвращает false, то есть падает сам вызов SDK
(spi_flash_erase_sector либо spi_flash_write). Самая вероятная причина —
сектор EEPROM за пределами физической микросхемы.

При раскладке 4MB/FS:2MB сектор EEPROM это 1019 (смещение 0x3FB000), что
требует чипа не меньше 4 МБ. Теперь лог сам печатает это смещение и
сравнивает с ESP.getFlashChipRealSize(), а также границы файловой системы —
последнее важно, потому что при том же дефекте LittleFS по адресу
0x400000 окажется недоступен ровно так же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:21:25 +07:00
oskarvitaliiandClaude Opus 5 fa54e5790f Диагностика сохранения EEPROM: проверять commit() и перечитывание
Настройки не сохраняются после перезагрузки. Причина пока не установлена,
это шаг сбора улик, а не исправление.

Найден дефект, мешавший диагностике: результат EEPROM.commit()
игнорировался, поэтому лог печатал «Конфигурация сохранена» безусловно —
даже при провалившейся записи. Теперь запись подтверждается повторным
чтением сектора из флеша и сравнением всех полей; при расхождении в лог
идут записанные и прочитанные значения.

Добавлена проверка геометрии флеша при старте: несовпадение размера по
настройке IDE с физическим уводит сектор EEPROM за пределы микросхемы,
и запись молча теряется — симптом ровно тот же.

Проверкой чтения исключены: корректность библиотеки EEPROM, раскладка
адресов (258 из 512, без перекрытий), стирание сектора со стороны
WiFiManager::resetSettings() — он вызывает только WiFi.disconnect().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:29:47 +07:00
oskarvitaliiandClaude Opus 5 cf61617a46 Настройки через веб, состояние реле в UI, распиновка под реле active-LOW
Распиновка. Пина D0, который использовался под выход «Резерв», в доступном
наборе нет. Кнопка сброса Wi-Fi удалена как избыточная: при пропавшей сети
wm.autoConnect() сам поднимает портал. Освободившийся D3 закрыл дефицит.

Выходы переехали на D3 и D4 и стали инверсными. Причина: для реле active-LOW
безопасное состояние — пин в HIGH, а D3/D4 подтянуты к HIGH внешними
резисторами платы и держат этот уровень всю загрузку. D8, наоборот, подтянут
к LOW и щёлкал бы реле при каждом включении питания. В setup() digitalWrite()
идёт до pinMode(), иначе защёлка выхода даёт короткий LOW.

Инверсия живёт только в setRelay(); логическое состояние дублируется в
relayMainOn/relayEmergOn, чтобы панель показывала смысл, а не уровень пина.

Настройки. device, выдержка аварии и выдержка резерва вынесены в /set_config
и EEPROM. Раскладка EEPROM версионирована: magic 0xA55B, старый 0xA55A
распознаётся и переносится, поэтому прошитые приборы не теряют webhook.
Значения по умолчанию воспроизводят прежнее поведение (5 с и 0 с).

Для резерва добавлена выдержка, которой раньше не было, симметрично основной
аварии. Обе выдержки гасят реле и вебхук одновременно.

device_name фильтруется до [A-Za-z0-9_-] вместо экранирования: такой набор
безопасен и в JSON, и в HTML. Пустое или неверное поле формы означает
«не менять», поэтому частичное заполнение не сбрасывает остальное.

Состояние реле выведено в /status и в панель.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра
3.1.2 — предупреждений в скетче нет (попутно убран неиспользуемый
isEmergency); валидация настроек прогнана на 22 граничных случаях;
раскладка EEPROM проверена на перекрытия. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:02:21 +07:00
oskarvitaliiandClaude Opus 5 4c760e943f Задокументировать два принятых ограничения
Отсутствие ретраев webhook — сознательное решение: автономная работа
без отправки событий это штатный режим.

Пауза мониторинга до 2 минут при старте без сохранённой сети
(блокирующий портал WiFiManager в setup()) — принята как есть,
с указанием способа лечения, если понадобится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:59:57 +07:00
oskarvitaliiandClaude Opus 5 355a9912ab Ограничить блокировку loop() при отправке webhook бюджетом 3 с
BearSSL делает TLS-хендшейк синхронно, поэтому вместо асинхронности
блокировка ограничивается по времени.

Главный источник залипания — не таймауты HTTPClient, а DNS:
WiFiClientSecureCtx::connect(name, port) резолвит имя через
WiFi.hostByName() без таймаута и подвешивает loop() на ~10 с.
Теперь имя разрешается заранее с лимитом 800 мс, результат попадает
в кэш lwIP, и внутренний резолв возвращается мгновенно. Соединение
по-прежнему идёт по имени, поэтому SNI и заголовок Host сохраняются.

Остаток бюджета уходит в http.setTimeout(): он ограничивает и
connect+TLS (через _client->setTimeout() до connect), и чтение
ответа (собственный цикл HTTPClient по _tcpTimeout).

Худший случай: ~3.5 с вместо прежних 10-15 с. Выходы аварии не
затронуты — digitalWrite() и раньше выполнялся до отправки.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против
ядра 3.1.2 — в скетче замечаний нет; разбор URL прогнан на 15
граничных случаях.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:40:42 +07:00
oskarvitaliiandClaude Opus 5 75430a3732 Initial commit: контроллер насосов v8.2 + документация
Прошивка Wemos D1 Mini для мониторинга трёх насосов: OLED,
веб-панель, webhook-уведомления, сброс Wi-Fi кнопкой.

Адрес webhook задаётся через веб-панель и хранится в EEPROM;
в исходнике не хранится, так как содержит секретный токен.

Добавлены README.md (техдокументация) и MANUAL.md (руководство).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:51:22 +07:00
5 changed files with 574 additions and 364 deletions
+102 -60
View File
@@ -60,12 +60,18 @@ GND). **Сирену, лампу или пускатель напрямую к `
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с > **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика > активным HIGH, реле будет включено в норме и отпускать при аварии — логика
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают > перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
> HIGH во время загрузки платы, поэтому реле молчат при включении питания. > HIGH во время загрузки, поэтому реле не остаются включёнными на всё время
> старта.
Оговорки, которые лучше знать заранее: Оговорки, которые лучше знать заранее:
* Реле «Авария» **щёлкает при каждой заливке прошивки**схема автосброса USB * **Оба реле кратковременно моргают при каждой перезагрузке прибора** — и при
дёргает `D3`. Это нормально. подаче питания, и при заливке прошивки. Так и есть, это нормально: выходы
сидят на служебных пинах платы, и пока прошивка не запустилась, уровнем на них
управляет не она. Автоматика в этот момент ещё не работает, ложных уведомлений
моргание не вызывает.
* Если моргание окажется недопустимым (например, сирена успевает пискнуть) —
ставится RC-цепочка на вход релейного модуля. Прошивку менять не нужно.
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для * Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
проверки монтажа. проверки монтажа.
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет. * Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
@@ -106,27 +112,33 @@ OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
## 4. Экран ## 4. Экран
``` ```
PUMP CONTROLLER v8 SUPPLY OK
──────────────────────── ────────────────────────
PUMP 1 : RUNNING P1:RUN P2:OFF BK:OFF
PUMP 2 : STOPPED ALARM:OFF BACKUP:OFF
BACKUP : STANDBY
──────────────────────── ────────────────────────
192.168.1.42 192.168.1.128
STATUS: OK
``` ```
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
| Надпись | Значение | | Надпись | Значение |
|---|---| |---|---|
| `PUMP 1/2 : RUNNING` | Насос работает | | `SUPPLY OK` | Норма, подача идёт |
| `PUMP 1/2 : STOPPED` | Насос остановлен | | `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
| `BACKUP : ACTIVE` | Резервный насос запущен | | `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
| `BACKUP : STANDBY` | Резерв в ожидании | | `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
| `WiFi: NO CONNECTION` | Нет связи с сетью | | `STARTING` | Идёт запуск, первые ~4 секунды |
| `INIT...` | Идёт запуск (первые ~4 секунды) |
| `STATUS: OK` | Норма | Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание | основные, `BK` — резерв.
| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** |
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
Нижняя строка — адрес в сети или `no network`, если связи нет.
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
--- ---
@@ -134,57 +146,81 @@ STATUS: OK
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети. Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
На странице: Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
лампы датчиков и выходов, в самом низу свёрнутые настройки.
* три карточки насосов с индикаторами; ![Вид панели: состояние «Подача есть», лампы насосов и выходов, раскрытые настройки](docs/img/panel.jpg)
* панель состояния системы (`SYS_OK` или `CRITICAL`);
* **состояние обоих реле** — «Включено» / «Выключено»;
* кнопка **Сбросить Wi-Fi**;
* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки.
Страница обновляется автоматически при изменении состояния насосов или реле. Так панель выглядит в норме. Здесь блок настроек раскрыт; обычно он свёрнут
в одну строку и не отвлекает.
### 5.1 Состояние реле ### 5.1 Главная плашка
Два индикатора показывают, что прибор реально подал на выход: | Плашка | Цвет | Что значит |
|---|---|---|
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
| **Запуск** | серый | Первые ~4 секунды после включения |
| Индикатор | Значение | Под заголовком — уточнение: какой именно насос работает, или «подачу держит
|---|---| резерв», если основные стоят, а резерв качает.
| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан |
| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает |
Полезно при пусконаладке: если реле показано «Включено», а нагрузка не Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке. система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
### 5.2 Настройки **Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
Все настройки в одной форме. **Пустое поле означает «не менять»** — можно ### 5.2 Лампы
поправить одну выдержку, не трогая остальное. Неверное значение так же
игнорируется, поэтому опечатка ничего не испортит. Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
или «ожидание».
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
«разомкнут». Это то, что прибор реально подал на реле.
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
проблема в монтаже или в типе релейного модуля, а не в прошивке.
### 5.3 Настройки
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
и не отвлекают.
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
испортит.
| Поле | Что задаёт | Допустимо | По умолчанию | | Поле | Что задаёт | Допустимо | По умолчанию |
|---|---|---|---| |---|---|---|---|
| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан | | **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` | | **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` | | **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) | | **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
секретный ключ, и выводить его на экран, который видно всем в помещении, не
нужно. Чтобы сменить адрес — введите новый целиком.
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
(см. раздел 7).
Настройки сохраняются в файл во внутренней памяти прибора, переживают Настройки сохраняются в файл во внутренней памяти прибора, переживают
отключение питания и сброс Wi-Fi. отключение питания и смену сети Wi-Fi.
> Если панель показывает красное предупреждение «Файловая система недоступна» > Если панель показывает красное предупреждение «Память недоступна» или
> или настройки пропадают после перезагрузки — причина в настройке `Flash Size` > настройки пропадают после перезагрузки — причина в настройке `Flash Size`
> при прошивке. Она обязана совпадать с фактическим размером памяти платы: > при прошивке. Она обязана совпадать с фактическим размером памяти платы:
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с > для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
> 2 МБ приводит ровно к такому симптому. > 2 МБ приводит ровно к такому симптому.
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут > Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки. > отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`. > Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
**На новом приборе адрес уведомлений не задан** — панель покажет **На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
`не задан - уведомления отключены`, сообщения отправляться не будут, всё уведомления отключены», сообщения отправляться не будут, всё остальное работает.
остальное работает.
--- ---
@@ -192,12 +228,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 +261,7 @@ STATUS: OK
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3). `Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите **Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку **Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
доступа. доступа.
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите **Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
@@ -238,15 +280,15 @@ STATUS: OK
| Событие | Задержка | | Событие | Задержка |
|---|---| |---|---|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) | | Фиксация нового состояния датчика | 3 секунды (не настраивается) |
| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с | | Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с | | Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** | | Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды | | Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
реле и на уведомление** — отдельно задержать только уведомление нельзя. реле и на уведомление** — отдельно задержать только уведомление нельзя.
Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
антидребезга. антидребезга.
--- ---
@@ -264,9 +306,9 @@ STATUS: OK
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера | | Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён | | Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки | | Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет | | Реле моргают при перезагрузке или заливке прошивки | Нормально, см. 2.3. Выходы на служебных пинах платы; пока прошивка не стартовала, уровнем управляет не она. Уведомления при этом не отправляются |
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` | | Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется | | Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание | | Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
Подробный лог работы выводится в USB-порт: скорость **115200 бод** Подробный лог работы выводится в USB-порт: скорость **115200 бод**
+183 -58
View File
@@ -12,10 +12,12 @@
| Файл | Назначение | | Файл | Назначение |
|---|---| |---|---|
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) | | `pump_controller_8_2_OLED_DONE.ino` | Логика прошивки |
| `web_page.h` | HTML веб-панели в PROGMEM (см. 7.7 — вынесен не для красоты) |
| `README.md` | Техническая документация (этот файл) | | `README.md` | Техническая документация (этот файл) |
| `MANUAL.md` | Руководство пользователя / монтажника | | `MANUAL.md` | Руководство пользователя / монтажника |
| `docs/specs/` | Согласованные проектные решения по крупным изменениям | | `docs/specs/` | Согласованные проектные решения по крупным изменениям |
| `docs/img/` | Снимки экрана для документации |
--- ---
@@ -53,7 +55,7 @@ Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на п
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания. выполнился, иначе реле останется включённым на всё время старта.
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке | | Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|---|---|---|---| |---|---|---|---|
@@ -77,12 +79,42 @@ Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на п
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); } void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
``` ```
#### Что происходит при перезагрузке на самом деле
Проверено на собранном приборе: **при перезагрузке оба реле кратковременно
моргают.** Признано некритичным и оставлено как есть.
Ранее в этом разделе утверждалось, что реле при загрузке молчат — это оказалось
неверно, и формулировка исправлена.
Наиболее вероятный механизм: оба выхода неизбежно сидят на служебных пинах.
`GPIO2` (`D4`) — это ещё и `UART1 TX`, и на нём во время загрузки появляется
служебный вывод; `GPIO0` (`D3`) — пин выбора режима загрузки, который дёргается
схемой сброса. Пока `setup()` не выполнился, уровнем на них управляет не
прошивка. Точную причину не измеряли: осциллографа в проверке не было.
Часть морганий может приходиться на светодиод релейного модуля, а не на
фактическое переключение контакта: короткий импульс успевает зажечь индикатор,
но не перебросить якорь. Различить можно тестером на контактах реле, если это
когда-нибудь станет важно.
**Почему решение всё равно верное.** Альтернатива — `D8` — не улучшила бы
ситуацию, а ухудшила: он подтянут к `LOW`, и реле было бы уверенно **включено
всю загрузку**, а не моргнуло бы. Кратковременное моргание предпочтительнее
устойчивого ложного сигнала на несколько секунд.
**Если моргание когда-нибудь станет проблемой** (например, сирена успевает
пискнуть): ставится RC-цепочка на вход релейного модуля, чтобы короткие импульсы
не проходили, либо модуль с триггером Шмитта на входе. Прошивку менять не
потребуется.
### 2.4 Прочие особенности ESP8266 ### 2.4 Прочие особенности ESP8266
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле * `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
«Резерв» получает бесплатную индикацию на плате. «Резерв» получает бесплатную индикацию на плате.
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его * `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке. вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
Кроме того, оба реле кратковременно моргают при любой перезагрузке (см. 2.3).
Косметика, но пугает. Косметика, но пугает.
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни * `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
@@ -213,18 +245,37 @@ pE = стоит -> реле «Резерв» выключено
`POST` на `settings.webhookUrl`, `Content-Type: application/json`: `POST` на `settings.webhookUrl`, `Content-Type: application/json`:
```json ```json
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"} {"event":"MAIN PUMPS","state":"ALARM",
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
``` ```
| `event` | `status` | | Поле | Назначение |
|---|---| |---|---|
| `MAIN PUMPS` | `ALARM` / `OK` | | `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` | | `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
| `status` | **Человеческая фраза**, та же что в панели |
| `device` | Имя прибора из настроек |
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель, Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до момент отправки — это те же слова, что видит оператор в панели:
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
запроса ему нечем. | Ситуация | `status` |
|---|---|
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
| Норма | `Подача есть, работает насос 1` |
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
разошлись бы при первой правке.
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
поэтому экранирование в JSON не требуется нигде.
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()`
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
@@ -391,50 +442,105 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
## 7. Веб-интерфейс ## 7. Веб-интерфейс
![Веб-панель контроллера: плашка состояния, лампы датчиков и выходов, раскрытые настройки](docs/img/panel.jpg)
*Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.*
Сервер на порту `80`. Сервер на порту `80`.
| Маршрут | Метод | Описание | | Маршрут | Метод | Описание |
|---|---|---| |---|---|---|
| `/` | `GET` | HTML-панель мониторинга | | `/` | `GET` | Статическая HTML-панель из PROGMEM |
| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` | | `/status` | `GET` | Живое состояние, JSON |
| `/config` | `GET` | Настройки и адрес в сети, JSON |
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться | | `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` | | `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
### 7.1 `/set_config` ### 7.1 Почему страница статическая
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
так частично заполненная форма не обнуляет остальные параметры. Запись в файл **14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
выполняется только если что-то реально изменилось. лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
| Параметр | Валидация | При отказе | Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
|---|---|---| `location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
| `url` | 1…220 символов | поле не меняется | пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
| `device` | фильтр до `[A-Za-z0-9_-]`, ≤31 симв.; пусто после фильтрации → отказ | поле не меняется | трогается.
| `alarm_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
| `emerg_delay` | строка целиком из цифр, 0…3600 | поле не меняется |
Фильтрация `device` вместо экранирования выбрана осознанно: разрешённый набор Ответы:
символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно
нигде.
### 7.2 Состояние реле ```json
/status {"p1":1,"p2":0,"pE":0,"rMain":0,"rEmg":0,"rdy":1,
"k":"ok","w":"Подача есть","s":"работает насос 1"}
/config {"device":"Nasosnaya_1","alarm":5,"emerg":0,
"host":"n8n.example.com","fs":1,"ip":"192.168.1.128"}
```
`rMain` и `rEmg` в `/status` — это **логическое** состояние реле из переменных `/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
`relayMainOn` / `relayEmergOn`, а не `digitalRead()` с пина. Так сделано секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели
обратное действительности.
### 7.3 Автообновление ### 7.2 Логика статуса и цвета
Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при любом Цвета взяты по конвенции сигнальных ламп, а не по вкусу:
изменении JSON. Поскольку состояние реле теперь входит в ответ, страница
обновляется и при срабатывании реле. Шрифты подгружаются с Google Fonts — при
отсутствии интернета у клиента интерфейс отрисуется системным моноширинным
шрифтом.
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить webhook | Состояние | Цвет | Заголовок | Условие |
и изменить выдержки. Контроллер рассчитан на изолированный технологический |---|---|---|---|
сегмент. | Запуск | серый | «Запуск» | `rdy = 0` |
| Норма | зелёный | «Подача есть» | работает хотя бы один основной |
| Внимание | **жёлтый** | «Работает резерв» | работает резерв, основные в порядке |
| Авария | красный | «Насосы стоят» | оба основных стоят |
Резерв намеренно **жёлтый, а не красный**: работающий резерв — предупреждение,
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
При аварии плашка медленно пульсирует; пульсация отключается при
`prefers-reduced-motion`.
### 7.3 Состояние реле
`rMain` и `rEmg`**логическое** состояние из переменных `relayMainOn` /
`relayEmergOn`, а не `digitalRead()` с пина. Выходы инверсные, и чтение уровня
показало бы на панели обратное действительности.
Лампа выхода «Авария» — **красная**. В первой версии панели она была зелёной:
стиль «включено» был общим для всех ламп, и сработавшая аварийная сигнализация
подсвечивалась цветом «всё хорошо».
### 7.4 Никаких внешних запросов
Веб-шрифты не подключаются. В первой версии был `@import` с Google Fonts, но
сеть на объекте не разрешает внешние имена (раздел 5.1), поэтому шрифты там не
загружались никогда — нарисованный макет на месте не появлялся. Сейчас
используются системные стеки, а характер задан контрастом кеглей: заголовок
статуса `clamp(28px, 8.5vw, 44px)` против меток 11–12 px.
### 7.5 Подпись состояния во вкладке
Заголовок вкладки и favicon меняют цвет вместе со статусом: `Насосы стоят ·
Nasosnaya_1` с красным кружком. Вкладка, забытая в фоне на телефоне, продолжает
сигналить, не требуя переключения на неё.
### 7.7 Почему разметка лежит в отдельном файле
`web_page.h` вынесен не ради опрятности, а вынужденно. Генератор прототипов
Arduino не понимает сырые строковые литералы (`R"=====( ... )====="`): он находит
внутри JavaScript строку `function draw(d){`, принимает её за определение функции
C++ и вставляет в начало файла прототип `function draw(d);`. Сборка падает с
`error: 'function' does not name a type`, причём директивы `#line` показывают
ошибку на строке внутри HTML.
Заголовочные файлы препроцессор не сканирует, поэтому страница живёт там.
Важное следствие для проверки: обычный `g++ -fsyntax-only` по `.ino` эту ошибку
**не покажет** — он компилирует файл как обычный `.cpp`, минуя препроцессор
Arduino. Проверять сборку нужно через `arduino-cli` (раздел 11.1).
### 7.6 Доступ
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить адрес
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
Контроллер рассчитан на изолированный технологический сегмент.
--- ---
@@ -442,28 +548,32 @@ Wi-Fi настройки из `/settings.json` не затрагивает, и
Обновление раз в секунду (`DISPLAY_INTERVAL`). Обновление раз в секунду (`DISPLAY_INTERVAL`).
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
менялся и ничего не сообщал.
``` ```
PUMP CONTROLLER v8 NO SUPPLY <- кегль 2, до 10 символов
──────────────────────── ────────────────────────
PUMP 1 : RUNNING P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
PUMP 2 : STOPPED ALARM:ON BACKUP:ON <- состояние выходов
BACKUP : STANDBY
──────────────────────── ────────────────────────
192.168.1.42 192.168.1.128
STATUS: OK
``` ```
Строка статуса: | Условие | Слово |
| Условие | Текст |
|---|---| |---|---|
| `!systemReady` | `INIT...` | | `!systemReady` | `STARTING` |
| оба основных стоят | `!! ALARM: NO PUMPS !!` (инверсия, мигание 500 мс) | | оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
| работает резерв | `WARN: BACKUP RUNNING` | | оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
| иначе | `STATUS: OK` | | работает резерв, основные в порядке | `BACKUP RUN` |
| иначе | `SUPPLY OK` |
Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`. тащить её во флеш ради четырёх слов не стоит.
Состояние обоих реле теперь выводится — освободились две строки после
объединения датчиков в одну.
--- ---
@@ -517,14 +627,29 @@ STATUS: OK
--- ---
## 11. Сборка ## 11. Сборка и прошивка
Плата в IDE: **LOLIN(WEMOS) D1 ESP-WROOM-02**, а не `D1 R2 & mini`. У WROOM-02
модуль на 2 МБ, и только у этой платы в списке есть нужные варианты `2MB (...)`.
`Flash Size` по умолчанию для неё — `2MB (FS:64KB OTA:~992KB)`, он и используется.
`arduino-cli` отдельно ставить не нужно, он идёт внутри Arduino IDE:
```bash ```bash
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino "C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 --warnings all .
``` ```
```bash ```bash
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino "C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" upload -p COM7 --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 .
``` ```
Монитор порта: 115200 бод. Монитор порта: **115200 бод**. Если заливка падает с
`could not open port 'COM7': PermissionError`, порт держит открытый монитор
Arduino IDE — закройте его.
### 11.1 Проверять сборку только через arduino-cli
Скетч нельзя надёжно проверить, скормив `.ino` напрямую в `g++`: так
пропускается препроцессор Arduino, а именно он ломается на некоторых
конструкциях (раздел 7.7). Ошибка, которую `g++ -fsyntax-only` не показывает,
на реальной сборке останавливает всё.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

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