Compare commits

...
29 Commits
Author SHA1 Message Date
oskarvitalii ac8a72b05d Merge dev into master: снимок веб-панели в документации 2026-08-08 09:32:58 +07:00
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
oskarvitalii 7a7075f6de Merge dev into master: исправлено описание поведения реле при загрузке
Документация утверждала, что реле при загрузке молчат. На собранном
приборе оба реле кратковременно моргают при перезагрузке; поведение
признано некритичным, прошивка не менялась, исправлен текст.
2026-08-08 01:55:23 +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
oskarvitalii f77ff376d4 Merge dev into master: переработанный интерфейс и текст события в webhook
Веб-панель переделана в световое табло: главный ответ читается с
расстояния, цвета по конвенции сигнальных ламп, настройки свёрнуты.
Исправлено: лампа выхода «Авария» горела зелёным при срабатывании,
обновление стирало ввод в форме, шрифты тянулись с Google Fonts и на
объекте не загружались.

Страница переехала во флеш (PROGMEM) и в отдельный файл web_page.h -
последнее вынужденно: генератор прототипов Arduino не понимает сырые
строковые литералы и ломался на JavaScript внутри.

OLED: верхняя строка отдана статусу кеглем 2, добавлено состояние реле.

В теле webhook поле status теперь содержит человеческую фразу
(«Насосы стоят, подачу держит резерв»), машиночитаемое значение
переехало в новое поле state.

Прошито и проверено на приборе: COM7, LOLIN D1 ESP-WROOM-02, 2MB FS:64KB.
2026-08-08 01:40:10 +07:00
oskarvitaliiandClaude Opus 5 0c06ba1032 feat(webhook): слать в status человеческую фразу вместо кода
В теле уведомления status теперь содержит ту же формулировку, что видит
оператор в панели: «Насосы стоят, подачу держит резерв». Прежние
машиночитаемые значения переехали в новое поле state (ALARM / OK /
STARTED / STOPPED), чтобы сценарий на сервере ветвился по нему, а не
разбирал русский текст.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:43:34 +07:00
oskarvitalii d53035a5ef Merge dev into master: pump_controller_8_2_OLED_DONE
Добавлен проект контроллера насосной станции на Wemos D1 mini вместе с
его историей: мониторинг двух основных и резервного насоса, два реле
аварий на D3/D4 (модули active-LOW), OLED, веб-панель с настройками и
состоянием реле, уведомления на HTTPS-webhook, настройки в LittleFS.

Также: .gitignore переведён на белый список, чтобы рабочий каталог
D:\Soft\Arduino мог служить рабочим деревом репозитория, не втягивая
в него старые версии скетчей, архивы и драйверы.
2026-08-07 23:20:29 +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
oskarvitaliiandClaude Opus 4.8 bda2ecb7a3 Merge dev into master: carousel dot fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:38 +07:00
oskarvitaliiandClaude Opus 4.8 d1f96007de fix(display): erase full icon height in boot carousel
The sun's lowest ray reached y+H, one row below the H-tall erase box,
leaving a stray dot/trail. Erase H+4 so nothing is left behind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:55:53 +07:00
oskarvitaliiandClaude Opus 4.8 208669d19f Merge dev into master: boot icon carousel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:51:03 +07:00
oskarvitaliiandClaude Opus 4.8 a0f96fc52d feat(display): boot icon carousel
On power-up, after the title splash the screen clears and each weather
icon slides in from the right to centre with its colour-coded label, then
slides out to the left — Fine, Fair, Changeable, Rain, Storm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:48:28 +07:00
oskarvitaliiandClaude Opus 4.8 68bdfdc879 Merge dev into master: RU/EN web interface + scrollable forecast log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:09 +07:00
oskarvitaliiandClaude Opus 4.8 98e8170b8b feat(web): RU/EN language toggle (Russian default)
Client-side i18n: static labels via data-i18n, dial zone words, and the
26 Zambretti phrases + categories + trend words translated in the browser.
Choice persists in localStorage. Device firmware unchanged (API stays
English; TFT stays Latin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:15:26 +07:00
oskarvitaliiandClaude Opus 4.8 e9ec610f1c feat(web): scrollable Forecast log with fixed height
Cap the log panel at ~216px with a thin themed scrollbar so a long
forecast history does not stretch the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:43:02 +07:00
13 changed files with 2407 additions and 38 deletions
+22
View File
@@ -1,12 +1,34 @@
# Рабочий каталог D:\Soft\Arduino содержит не только проекты этого репозитория,
# но и старые версии скетчей, эксперименты, архивы и драйверы. Поэтому здесь
# белый список: по умолчанию игнорируется всё, а в репозиторий попадают только
# перечисленные ниже проекты.
#
# Чтобы добавить проект: допишите строку "!/ИмяПапки/" в блок ниже.
# Имя папки должно совпадать с именем .ino внутри — требование Arduino IDE.
/*
!/.gitignore
!/README.md
!/WeatherPredictor/
!/pump_controller_8_2_OLED_DONE/
# --- Правила ниже действуют внутри разрешённых проектов ---
# Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому
# артефакты сборки исключаются даже внутри отслеживаемых папок.
# Arduino / build artifacts
build/
*.bin
*.elf
*.hex
.vscode/
# OS
Thumbs.db
Desktop.ini
.DS_Store
# Secrets (Wi-Fi creds are set via WiFiManager, not committed)
secrets.h
+7
View File
@@ -7,3 +7,10 @@ A collection of Arduino / ESP microcontroller projects.
- **[WeatherPredictor](WeatherPredictor/)** — autonomous barometric weather
station on a Wemos D1 mini (ESP8266): local Zambretti forecast, TFT display
and a self-hosted web interface. See its [README](WeatherPredictor/README.md).
- **[pump_controller_8_2_OLED_DONE](pump_controller_8_2_OLED_DONE/)** — pump
station monitor on a Wemos D1 mini (ESP8266): watches two main pumps plus a
backup one, drives two alarm relays, shows status on an OLED and in a web
panel, and posts events to an HTTPS webhook. Settings live in LittleFS.
See its [README](pump_controller_8_2_OLED_DONE/README.md) and
[MANUAL](pump_controller_8_2_OLED_DONE/MANUAL.md).
+2
View File
@@ -43,6 +43,8 @@ void setup() {
displayBegin();
displaySplash("Weather", "Predictor");
delay(800);
displayDemo();
if (!sensorsBegin()) Serial.println(F("BMP180 not found!"));
if (!rtcBegin()) Serial.println(F("DS3231 not found!"));
+40
View File
@@ -99,6 +99,46 @@ static void drawIcon(WxCategory c, int x, int y, int W, int H) {
}
// Layout for landscape 160 x 80.
// Boot demo: each weather icon slides in from the right to centre, with its
// category label below in the matching colour.
void displayDemo() {
WxCategory cats[5] = {WX_FINE, WX_FAIR, WX_CHANGEABLE, WX_RAIN, WX_STORM};
const char* names[5] = {"Fine", "Fair", "Changeable", "Rain", "Storm"};
const int W = 64, H = 52, y = 4;
const int cxc = (160 - W) / 2; // centred icon x
tft.fillScreen(ST77XX_BLACK); // clear the splash before the carousel
for (int k = 0; k < 5; k++) {
// slide in from the right edge to centre
int px = 160 + 12;
for (int x = 160; x >= cxc; x -= 12) {
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK);
drawIcon(cats[k], x, y, W, H);
px = x;
delay(26);
}
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK);
drawIcon(cats[k], cxc, y, W, H);
// label centred below in the category colour
int len = 0; while (names[k][len]) len++; // size 2 = 12 px/char
tft.setTextSize(2);
tft.setTextColor(catColor(cats[k]));
tft.setCursor((160 - len * 12) / 2, 60);
tft.print(names[k]);
delay(360);
tft.fillRect(0, 60, 160, 20, ST77XX_BLACK); // drop the label before it leaves
// slide out to the left edge
px = cxc;
for (int x = cxc - 12; x >= -W; x -= 12) {
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK);
drawIcon(cats[k], x, y, W, H);
px = x;
delay(24);
}
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK); // clear the last remnant
}
tft.setTextColor(ST77XX_WHITE);
}
void displayRender(const AppState& s) {
tft.fillScreen(ST77XX_BLACK);
tft.setTextColor(ST77XX_WHITE);
+1
View File
@@ -4,4 +4,5 @@
void displayBegin();
void displaySplash(const char* line1, const char* line2);
void displayDemo(); // boot animation: icons slide in one by one
void displayRender(const AppState& s);
+135 -38
View File
@@ -39,6 +39,11 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
}
.lede{color:var(--muted); font-size:.92rem; margin:8px 0 0}
.lede b{color:var(--text); font-weight:600}
header{position:relative}
.lang{position:absolute; top:0; right:0; display:flex; gap:3px}
.lang button{padding:4px 9px; font-size:.72rem; font-weight:700; letter-spacing:.06em; font-family:var(--sans);
background:transparent; color:var(--muted); border:1px solid var(--line); border-radius:7px; cursor:pointer}
.lang button.on{background:var(--brass); color:#221a06; border-color:var(--brass)}
.panel{
background:linear-gradient(180deg, var(--surface), #14203a);
@@ -153,9 +158,10 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<div class="wrap">
<header>
<p class="eyebrow">Barometric Forecaster</p>
<h1>Weather&nbsp;Predictor</h1>
<p class="lede">Reading the sky by pressure alone, the <b>Zambretti</b> way station at
<div class="lang" id="lang"><button type="button" data-lang="ru">RU</button><button type="button" data-lang="en">EN</button></div>
<p class="eyebrow" data-i18n="eyebrow">Barometric Forecaster</p>
<h1 data-i18n="title">Weather Predictor</h1>
<p class="lede"><span data-i18n="lede">Reading the sky by pressure alone, the Zambretti way station at</span>
<span id="coords">54.99&deg;N, 82.87&deg;E</span>.</p>
</header>
@@ -164,7 +170,7 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<svg id="gauge" viewBox="0 0 400 232" role="img" aria-label="Barometer dial"></svg>
<div class="readout">
<span class="pressure"><span id="msl">----</span></span>
<span class="unit">hPa&nbsp;·&nbsp;sea level</span>
<span class="unit" data-i18n="unit_msl">hPa&nbsp;·&nbsp;sea level</span>
<span class="chip" id="chip" style="color:var(--muted)">&mdash;</span>
</div>
<p class="verdict" id="verdict">Reading the sky&hellip;</p>
@@ -173,18 +179,18 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<!-- Stats -->
<section class="panel stats">
<div class="stat"><div class="k">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">&mdash;</div></div>
<div class="stat"><div class="k">Temperature</div><div class="v" id="temp">--</div><div class="s">degrees celsius</div></div>
<div class="stat"><div class="k">3-hour move</div><div class="v" id="delta">&mdash;</div><div class="s" id="deltas">&mdash;</div></div>
<div class="stat"><div class="k" data-i18n="k_time">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">&mdash;</div></div>
<div class="stat"><div class="k" data-i18n="k_temp">Temperature</div><div class="v" id="temp">--</div><div class="s" data-i18n="s_celsius">degrees celsius</div></div>
<div class="stat"><div class="k" data-i18n="k_move">3-hour move</div><div class="v" id="delta">&mdash;</div><div class="s" id="deltas">&mdash;</div></div>
</section>
<!-- Barograph -->
<section class="panel panel-pad">
<div class="head">
<h2>Barograph</h2>
<h2 data-i18n="barograph">Barograph</h2>
<div class="legend">
<span><i style="background:var(--change)"></i>Pressure</span>
<span><i style="background:var(--fair)"></i>Temperature</span>
<span><i style="background:var(--change)"></i><span data-i18n="pressure">Pressure</span></span>
<span><i style="background:var(--fair)"></i><span data-i18n="temperature">Temperature</span></span>
</div>
</div>
<div class="baro">
@@ -199,7 +205,7 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<!-- Forecast log -->
<section class="panel panel-pad">
<div class="head">
<h2>Forecast log</h2>
<h2 data-i18n="flog">Forecast log</h2>
<span class="cap" id="log-cap"></span>
</div>
<ol class="log" id="log"></ol>
@@ -207,27 +213,27 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<!-- Settings -->
<details class="panel set">
<summary>Station settings <span class="caret">Adjust</span></summary>
<summary><span data-i18n="settings">Station settings</span> <span class="caret" data-i18n="adjust">Adjust</span></summary>
<hr class="rule">
<div class="form">
<div style="margin-top:18px">
<label>City preset</label>
<label data-i18n="l_city">City preset</label>
<select id="s-city"></select>
</div>
<div class="grid2" style="margin-top:14px">
<div><label>Altitude &middot; metres</label><input id="s-alt" type="number" step="1"></div>
<div><label>Time zone</label><select id="s-tz"></select></div>
<div><label>Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
<div><label>Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
<div><label data-i18n="l_alt">Altitude &middot; metres</label><input id="s-alt" type="number" step="1"></div>
<div><label data-i18n="l_tz">Time zone</label><select id="s-tz"></select></div>
<div><label data-i18n="l_lat">Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
<div><label data-i18n="l_lon">Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
</div>
<div class="actions">
<button id="save">Save settings</button>
<button id="save" data-i18n="save">Save settings</button>
<span class="saved" id="saved"></span>
</div>
</div>
</details>
<footer>Served from the station &middot; works without internet</footer>
<footer data-i18n="footer">Served from the station &middot; works without internet</footer>
</div>
<script>
@@ -242,6 +248,93 @@ var ZONES=[
];
var CATCOLOR={Fine:"#8FBE8A",Fair:"#E4B658",Changeable:"#79C2D0",Rain:"#5C86D6",Storm:"#E4573B"};
// ---------- i18n ----------
var LANG=(function(){ try{ return localStorage.getItem("wp_lang")||"ru"; }catch(e){ return "ru"; } })();
var STR={
ru:{
eyebrow:"Барометрический предсказатель", title:"Предсказатель погоды",
lede:"Читаем небо по одному давлению — метод Zambretti. Станция:",
unit_msl:"гПа · у моря",
k_time:"Местное время", k_temp:"Температура", k_move:"Изм. за 3 ч", s_celsius:"градусы Цельсия",
barograph:"Барограф", pressure:"Давление", temperature:"Температура", flog:"Журнал прогнозов",
settings:"Настройки станции", adjust:"Изменить",
l_city:"Пресет города", l_alt:"Высота · метры", l_tz:"Часовой пояс", l_lat:"Широта", l_lon:"Долгота",
custom:"Вручную", save:"Сохранить", footer:"Отдаётся станцией · работает без интернета",
warm:"Читаем небо…", warm_chip:"прогрев",
warm_sub:"Прогнозу нужно около трёх часов истории давления до первого вывода.",
verdict_pre:"Показание Zambretti — ", verdict_post:" за последние три часа.",
tr_rising:"давление растёт", tr_falling:"давление падает", tr_steady:"давление стабильно",
collecting:"Сбор данных…",
hint:function(n,h){return n+" измерений за "+h+" ч · давление в гПа, температура в °C";},
log_empty:"Пока прогнозов нет — первый появится примерно через три часа.",
log_cap:function(n){var d1=n%10,d2=n%100; return n+((d1===1&&d2!==11)?" смена":((d1>=2&&d1<=4&&(d2<10||d2>=20))?" смены":" смен"));},
need3:"нужно 3 ч данных", mv_unit:"гПа · ", mv_rising:"рост", mv_steady:"стабильно", mv_falling:"падение",
saved_ok:"Сохранено.", saved_err:"Не удалось сохранить.", saved_net:"Станция недоступна."
},
en:{
eyebrow:"Barometric Forecaster", title:"Weather Predictor",
lede:"Reading the sky by pressure alone, the Zambretti way — station at",
unit_msl:"hPa · sea level",
k_time:"Local time", k_temp:"Temperature", k_move:"3-hour move", s_celsius:"degrees celsius",
barograph:"Barograph", pressure:"Pressure", temperature:"Temperature", flog:"Forecast log",
settings:"Station settings", adjust:"Adjust",
l_city:"City preset", l_alt:"Altitude · metres", l_tz:"Time zone", l_lat:"Latitude", l_lon:"Longitude",
custom:"Custom / manual entry", save:"Save settings", footer:"Served from the station · works without internet",
warm:"Reading the sky…", warm_chip:"warming up",
warm_sub:"The forecaster needs about three hours of pressure history before its first call.",
verdict_pre:"Zambretti reading — ", verdict_post:" over the last three hours.",
tr_rising:"pressure rising", tr_falling:"pressure falling", tr_steady:"pressure steady",
collecting:"Collecting data…",
hint:function(n,h){return n+" readings over "+h+" h · pressure in hPa, temperature in °C";},
log_empty:"No forecasts yet — the first call appears after about three hours.",
log_cap:function(n){return n+(n>1?" changes":" change");},
need3:"need 3 h of data", mv_unit:"hPa · ", mv_rising:"rising", mv_steady:"steady", mv_falling:"falling",
saved_ok:"Saved.", saved_err:"Could not save.", saved_net:"Could not reach the station."
}
};
function T(k){ var s=STR[LANG]||STR.en; return (k in s)?s[k]:(STR.en[k]!=null?STR.en[k]:k); }
var ZONE_RU={STORMY:"БУРЯ",RAIN:"ДОЖДЬ",CHANGE:"ПЕРЕМЕННО",FAIR:"ЯСНО","VERY DRY":"СУШЬ"};
function zoneLabel(z){ return LANG==="ru"?(ZONE_RU[z]||z):z; }
var CATS={Fine:"Ясно",Fair:"Хорошо",Changeable:"Переменно",Rain:"Дождь",Storm:"Буря"};
function trCat(s){ return (LANG==="ru"&&CATS[s])?CATS[s]:s; }
var PHRASES={
"Settled fine weather":"Устойчиво ясно",
"Fine weather":"Ясная погода",
"Becoming fine":"Проясняется",
"Fine, becoming less settled":"Ясно, погода портится",
"Fine, possibly showers":"Ясно, возможны ливни",
"Fairly fine, improving":"Довольно ясно, улучшение",
"Fairly fine, possibly showers early":"Довольно ясно, ранние ливни",
"Fairly fine, showers later":"Довольно ясно, ливни позже",
"Showery early, improving":"Ранние ливни, улучшение",
"Changeable, improving":"Переменно, улучшение",
"Fairly fine, showers likely":"Довольно ясно, вероятны ливни",
"Rather unsettled, clearing later":"Неустойчиво, позже прояснение",
"Unsettled, probably improving":"Неустойчиво, вероятно улучшение",
"Showery, bright intervals":"Ливни, прояснения",
"Showery, becoming unsettled":"Ливни, погода портится",
"Changeable, some rain":"Переменно, местами дождь",
"Unsettled, short fine intervals":"Неустойчиво, краткие прояснения",
"Unsettled, rain later":"Неустойчиво, дождь позже",
"Unsettled, rain at times":"Неустойчиво, временами дождь",
"Very unsettled, finer at times":"Очень неустойчиво, временами лучше",
"Rain at times, worse later":"Временами дождь, позже хуже",
"Rain at times, becoming very unsettled":"Временами дождь, резко портится",
"Rain at frequent intervals":"Частые дожди",
"Very unsettled, rain":"Очень неустойчиво, дождь",
"Stormy, possibly improving":"Буря, возможно улучшение",
"Stormy, much rain":"Буря, сильный дождь"
};
function trPhrase(s){ return (LANG==="ru"&&PHRASES[s])?PHRASES[s]:s; }
function applyLang(){
document.documentElement.lang=LANG;
document.querySelectorAll("[data-i18n]").forEach(function(el){ el.textContent=T(el.getAttribute("data-i18n")); });
ZONES.forEach(function(z){ var lab=document.getElementById("lab-"+z.label); if(lab) lab.textContent=zoneLabel(z.label); });
var city=document.getElementById("s-city"); if(city&&city.options.length) city.options[0].textContent=T("custom");
document.querySelectorAll("#lang button").forEach(function(b){ b.classList.toggle("on", b.getAttribute("data-lang")===LANG); });
}
function setLang(l){ LANG=l; try{localStorage.setItem("wp_lang",l);}catch(e){} applyLang(); refresh(); }
function clamp(v,a,b){return v<a?a:(v>b?b:v);}
// pressure -> screen angle (deg): 960=180 (left), 1060=0 (right)
function angleOf(p){return 180 - (clamp(p,PMIN,PMAX)-PMIN)/(PMAX-PMIN)*180;}
@@ -329,15 +422,15 @@ async function refresh(){
// verdict
var v=document.getElementById("verdict"), vs=document.getElementById("verdict-sub"), chip=document.getElementById("chip");
if(c.haveTrend){
v.textContent=c.forecast;
v.textContent=trPhrase(c.forecast);
var col=CATCOLOR[c.category]||"#93A3BE";
chip.textContent=c.category; chip.style.color=col;
var word=c.trend>0?"pressure rising":(c.trend<0?"pressure falling":"pressure steady");
vs.textContent="Zambretti reading — "+word+" over the last three hours.";
chip.textContent=trCat(c.category); chip.style.color=col;
var word=c.trend>0?T("tr_rising"):(c.trend<0?T("tr_falling"):T("tr_steady"));
vs.textContent=T("verdict_pre")+word+T("verdict_post");
}else{
v.textContent="Reading the sky…";
chip.textContent="warming up"; chip.style.color="#93A3BE";
vs.textContent="The forecaster needs about three hours of pressure history before its first call.";
v.textContent=T("warm");
chip.textContent=T("warm_chip"); chip.style.color="#93A3BE";
vs.textContent=T("warm_sub");
}
document.getElementById("chip").setAttribute("aria-label",c.category||"");
// absolute already implied; keep footer clean
@@ -356,7 +449,7 @@ async function drawLog(){
ol.innerHTML="";
if(!data.length){
var li=document.createElement("li"); li.className="empty";
li.textContent="No forecasts yet — the first call appears after about three hours.";
li.textContent=T("log_empty");
ol.appendChild(li); cap.textContent=""; return;
}
data.forEach(function(e){
@@ -364,13 +457,13 @@ async function drawLog(){
var dot=document.createElement("span"); dot.className="dot";
dot.style.background=CATCOLOR[e.cat]||"#93A3BE"; li.appendChild(dot);
var lt=document.createElement("div"); lt.className="lt";
var b=document.createElement("b"); b.textContent=e.text; lt.appendChild(b);
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=e.cat; lt.appendChild(lc);
var b=document.createElement("b"); b.textContent=trPhrase(e.text); lt.appendChild(b);
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=trCat(e.cat); lt.appendChild(lc);
li.appendChild(lt);
var tm=document.createElement("time"); tm.textContent=fmtStamp(e.t); li.appendChild(tm);
ol.appendChild(li);
});
cap.textContent=data.length+(data.length>1?" changes":" change");
cap.textContent=T("log_cap")(data.length);
}
function fillAxis(id,mx,mn,deg){
var box=document.getElementById(id); box.innerHTML="";
@@ -387,7 +480,7 @@ async function drawChart(){
var svg=document.getElementById("chart"), W=640,H=200,pad=6;
while(svg.firstChild) svg.removeChild(svg.firstChild);
if(!data.length){
document.getElementById("hint").textContent="Collecting data…";
document.getElementById("hint").textContent=T("collecting");
document.getElementById("axL").innerHTML=""; document.getElementById("axR").innerHTML="";
document.getElementById("axT0").textContent=""; document.getElementById("axT1").textContent="";
updateSetHand(); return;
@@ -412,7 +505,7 @@ async function drawChart(){
document.getElementById("axT0").textContent=fmtTime(data[0].t);
document.getElementById("axT1").textContent=fmtTime(data[n-1].t);
var hrs=((data[n-1].t-data[0].t)/3600);
document.getElementById("hint").textContent=n+" readings over "+hrs.toFixed(1)+" h · pressure in hPa, temperature in °C";
document.getElementById("hint").textContent=T("hint")(n, hrs.toFixed(1));
updateSetHand();
}
@@ -425,13 +518,13 @@ function updateSetHand(){
for(var i=0;i<d.length;i++){ if(d[i].t<=target) past=d[i]; }
if(!past || (latest.t-past.t)<9000){ sh.setAttribute("opacity",0);
document.getElementById("delta").textContent="";
document.getElementById("deltas").textContent="need 3 h of data"; return; }
document.getElementById("deltas").textContent=T("need3"); return; }
setNeedle("sethand",past.msl);
sh.setAttribute("opacity",.7);
var diff=latest.msl-past.msl;
document.getElementById("delta").textContent=fmtSigned(diff,1);
var word=diff>1.6?"rising":(diff<-1.6?"falling":"steady");
document.getElementById("deltas").textContent="hPa · "+word;
var word=diff>1.6?T("mv_rising"):(diff<-1.6?T("mv_falling"):T("mv_steady"));
document.getElementById("deltas").textContent=T("mv_unit")+word;
}
function tzLabel(min){var s=min<0?"-":"+";var a=Math.abs(min);var h=Math.floor(a/60);var m=a%60;return "UTC"+s+h+(m?(":"+(m<10?"0":"")+m):"");}
@@ -457,7 +550,7 @@ function setTz(min){
}
function buildCities(){
var sel=document.getElementById("s-city");
var o0=document.createElement("option"); o0.value="-1"; o0.textContent="Custom / manual entry"; sel.appendChild(o0);
var o0=document.createElement("option"); o0.value="-1"; o0.textContent=T("custom"); sel.appendChild(o0);
CITIES.forEach(function(c,i){
var o=document.createElement("option"); o.value=i;
o.textContent=c.n+" · "+c.alt+" m · "+tzLabel(c.tz); sel.appendChild(o);
@@ -497,16 +590,20 @@ document.getElementById("save").addEventListener("click", async function(){
var msg=document.getElementById("saved");
try{
var r=await (await fetch("/api/settings",{method:"POST",body:JSON.stringify(body)})).json();
msg.textContent=r.ok?"Saved.":"Could not save.";
msg.textContent=r.ok?T("saved_ok"):T("saved_err");
msg.style.color=r.ok?"var(--fine)":"var(--storm)";
if(r.ok){ loadSettings(); refresh(); }
}catch(e){ msg.textContent="Could not reach the station."; msg.style.color="var(--storm)"; }
}catch(e){ msg.textContent=T("saved_net"); msg.style.color="var(--storm)"; }
setTimeout(function(){msg.textContent="";},4000);
});
document.querySelectorAll("#lang button").forEach(function(b){
b.addEventListener("click",function(){ setLang(b.getAttribute("data-lang")); });
});
buildGauge();
buildTz();
buildCities();
applyLang();
loadSettings();
refresh();
setInterval(refresh,15000);
+9
View File
@@ -0,0 +1,9 @@
build/
.build/
*.bin
*.elf
*.hex
*.map
.vscode/
.DS_Store
Thumbs.db
+328
View File
@@ -0,0 +1,328 @@
# Контроллер насосов v8.2 — руководство пользователя
Документ для монтажника и обслуживающего персонала.
Техническое описание кода — в [README.md](README.md).
---
## 1. Что делает прибор
Контроллер следит за тремя насосами:
* **Насос 1** и **Насос 2** — основные;
* **Резерв** — аварийный (экстренный) насос.
Прибор:
1. показывает состояние на экране;
2. включает выход «Авария», если **оба** основных насоса остановлены;
3. включает выход «Резерв», когда запускается резервный насос;
4. отправляет уведомления на сервер (webhook) через интернет;
5. показывает панель мониторинга в браузере в локальной сети.
---
## 2. Подключение
### 2.1 Питание
5 В через microUSB (блок питания не менее 500 мА).
### 2.2 Датчики насосов
К каждому входу подключается **сухой контакт** — свободный контакт пускателя
насоса или датчика потока.
| Клемма платы | Что подключить |
|---|---|
| `D5` и `GND` | Контакт насоса 1 |
| `D6` и `GND` | Контакт насоса 2 |
| `D7` и `GND` | Контакт резервного насоса |
**Контакт замкнут = насос работает.** Полярность не важна, внешние резисторы
и питание на контакты подавать не нужно.
### 2.3 Выходы на реле
Прибор рассчитан на **релейные модули с активным LOW** — те, у которых реле
включается замыканием входа на GND. Это самый распространённый тип модулей для
Arduino.
| Клемма платы | Куда | Реле включается когда |
|---|---|---|
| `D3` | вход `IN` модуля «Авария» | оба основных насоса стоят |
| `D4` | вход `IN` модуля «Резерв» | работает резервный насос |
Питание модулей — от `5V` и `GND` платы (или от отдельного источника с общим
GND). **Сирену, лампу или пускатель напрямую к `D3`/`D4` подключать нельзя**
только через релейный модуль или транзисторный ключ.
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
> HIGH во время загрузки, поэтому реле не остаются включёнными на всё время
> старта.
Оговорки, которые лучше знать заранее:
* **Оба реле кратковременно моргают при каждой перезагрузке прибора** — и при
подаче питания, и при заливке прошивки. Так и есть, это нормально: выходы
сидят на служебных пинах платы, и пока прошивка не запустилась, уровнем на них
управляет не она. Автоматика в этот момент ещё не работает, ложных уведомлений
моргание не вызывает.
* Если моргание окажется недопустимым (например, сирена успевает пискнуть) —
ставится RC-цепочка на вход релейного модуля. Прошивку менять не нужно.
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
проверки монтажа.
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
Если это критично — снимайте нагрузку с **нормально замкнутого (NC)** контакта
реле, тогда пропажа питания читается системой как авария.
### 2.4 Свободные клеммы
`D8`, `D0` и `A0` прошивкой не используются. Учтите, что `A0` — только
аналоговый вход и цифровым выходом стать не может.
### 2.5 Экран
OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
---
## 3. Первое включение
1. Подайте питание. На экране появится `PUMP CTRL v8 / Starting...`.
2. Прибор не найдёт сохранённой сети и поднимет свою точку доступа:
```
Имя сети: Pump_Control_Set
```
3. Подключитесь к ней с телефона или ноутбука (пароль не требуется).
4. Откройте в браузере `http://192.168.4.1` — откроется страница настройки.
5. Выберите вашу Wi-Fi-сеть, введите пароль, нажмите **Save**.
6. Прибор перезагрузится и покажет на экране полученный IP-адрес.
**Запишите этот адрес** — по нему открывается панель мониторинга.
Если за 2 минуты сеть не настроена, прибор переходит в автономный режим:
экран, выходы аварии и логика работают, webhook и веб-панель — нет.
---
## 4. Экран
```
SUPPLY OK
────────────────────────
P1:RUN P2:OFF BK:OFF
ALARM:OFF BACKUP:OFF
────────────────────────
192.168.1.128
```
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
| Надпись | Значение |
|---|---|
| `SUPPLY OK` | Норма, подача идёт |
| `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
| `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
| `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
| `STARTING` | Идёт запуск, первые ~4 секунды |
Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
основные, `BK` — резерв.
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
Нижняя строка — адрес в сети или `no network`, если связи нет.
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
---
## 5. Панель в браузере
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
лампы датчиков и выходов, в самом низу свёрнутые настройки.
![Вид панели: состояние «Подача есть», лампы насосов и выходов, раскрытые настройки](docs/img/panel.jpg)
Так панель выглядит в норме. Здесь блок настроек раскрыт; обычно он свёрнут
в одну строку и не отвлекает.
### 5.1 Главная плашка
| Плашка | Цвет | Что значит |
|---|---|---|
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
| **Запуск** | серый | Первые ~4 секунды после включения |
Под заголовком — уточнение: какой именно насос работает, или «подачу держит
резерв», если основные стоят, а резерв качает.
Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
**Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
### 5.2 Лампы
Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
или «ожидание».
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
«разомкнут». Это то, что прибор реально подал на реле.
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
проблема в монтаже или в типе релейного модуля, а не в прошивке.
### 5.3 Настройки
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
и не отвлекают.
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
испортит.
| Поле | Что задаёт | Допустимо | По умолчанию |
|---|---|---|---|
| **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
| **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
| **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
| **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
секретный ключ, и выводить его на экран, который видно всем в помещении, не
нужно. Чтобы сменить адрес — введите новый целиком.
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
(см. раздел 7).
Настройки сохраняются в файл во внутренней памяти прибора, переживают
отключение питания и смену сети Wi-Fi.
> Если панель показывает красное предупреждение «Память недоступна» или
> настройки пропадают после перезагрузки — причина в настройке `Flash Size`
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
> 2 МБ приводит ровно к такому симптому.
> Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
уведомления отключены», сообщения отправляться не будут, всё остальное работает.
---
## 6. Уведомления на сервер
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
В сообщении приходит **та же фраза, что показывает панель** — читать можно
без расшифровки кодов:
| Ситуация | Текст в сообщении |
|---|---|
| Оба основных стоят, резерв качает | «Насосы стоят, подачу держит резерв» |
| Оба основных стоят, резерва нет | «Насосы стоят, подачи нет» |
| Работает резерв, основные в порядке | «Работает резерв, основные насосы в работе» |
| Работа восстановлена | «Подача есть, работает насос 1» |
Рядом с текстом передаётся служебное поле для автоматики: `MAIN PUMPS` или
`EMERGENCY PUMP` и `ALARM` / `OK` / `STARTED` / `STOPPED`.
Уведомления отправляются один раз на каждое изменение, а не повторяются.
Если в момент события связи не было — уведомление **не будет** отправлено
позже, оно теряется.
На отправку отводится не больше **3 секунд**. Если сервер недоступен или
отвечает медленно, прибор прекращает попытку и продолжает работу: экран и
кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не
дожидаясь сервера.
---
## 7. Сброс настроек Wi-Fi
Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет.
**Если сеть пропала или сменился пароль** — ничего делать не нужно: при
перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
доступа.
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
прибор по питанию. Не найдя сеть, он откроет портал настройки.
Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при
сбросе Wi-Fi **не стираются**.
---
## 8. Задержки срабатывания
Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и
остановки:
| Событие | Задержка |
|---|---|
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
реле и на уведомление** — отдельно задержать только уведомление нельзя.
Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
антидребезга.
---
## 9. Диагностика
| Симптом | Причина / что делать |
|---|---|
| Экран не горит | Проверьте питание и провода SDA/SCL. Если адрес дисплея `0x3D`, требуется правка прошивки (константа `OLED_ADDR`) |
| Экран горит, но насосы всегда `STOPPED` | Контакты датчиков не замыкаются на `GND`; проверьте общий провод |
| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя |
| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) |
| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора |
| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя |
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
| Реле моргают при перезагрузке или заливке прошивки | Нормально, см. 2.3. Выходы на служебных пинах платы; пока прошивка не стартовала, уровнем управляет не она. Уведомления при этом не отправляются |
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
(Arduino IDE → Инструменты → Монитор порта).
---
## 10. Безопасность
Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может
открыть панель, сбросить Wi-Fi, изменить адрес уведомлений и **увеличить
выдержки аварии**, то есть замедлить реакцию сигнализации. Устанавливайте прибор
в закрытом технологическом сегменте сети, не в гостевом Wi-Fi.
Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты.
Он не заменяет штатную автоматику защиты насосной станции. Кроме того, схема не
отказобезопасна: обесточенный контроллер сигнала аварии не подаёт (см. 2.3).
+655
View File
@@ -0,0 +1,655 @@
# Pump Controller v8.2 (OLED)
Контроллер мониторинга насосной станции на **Wemos D1 Mini (ESP8266)**.
Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие
контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в
локальной сети и отправляет события на HTTPS-webhook (n8n).
---
## 1. Состав репозитория
| Файл | Назначение |
|---|---|
| `pump_controller_8_2_OLED_DONE.ino` | Логика прошивки |
| `web_page.h` | HTML веб-панели в PROGMEM (см. 7.7 — вынесен не для красоты) |
| `README.md` | Техническая документация (этот файл) |
| `MANUAL.md` | Руководство пользователя / монтажника |
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
| `docs/img/` | Снимки экрана для документации |
---
## 2. Аппаратная часть
### 2.1 Плата
Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на пин `3V3`.
**Флеш на целевой плате — 2 МБ.** Это важно: клоны Wemos D1 Mini встречаются с
1, 2 и 4 МБ, а размер, выбранный в IDE, обязан совпадать с физическим. Иначе
области EEPROM и файловой системы окажутся за пределами микросхемы и настройки
не сохранятся — см. раздел 6.1, там разобран ровно этот случай.
Проверить фактический размер: `ESP.getFlashChipRealSize()`, прошивка печатает
его в лог при старте строкой `[FLASH] по настройке IDE: ... физически: ...`.
### 2.2 Распиновка
Выходы рассчитаны на релейные модули **active-LOW** (`LOW` на входе = реле
включено).
| Пин | Имя в коде | Режим | Логика |
|---|---|---|---|
| `D1` | I2C SDA | — | OLED |
| `D2` | I2C SCL | — | OLED |
| `D5` | `IN_PUMP_1` | `INPUT_PULLUP` | `LOW` = насос 1 работает |
| `D6` | `IN_PUMP_2` | `INPUT_PULLUP` | `LOW` = насос 2 работает |
| `D7` | `IN_PUMP_EMERGENCY` | `INPUT_PULLUP` | `LOW` = резервный насос работает |
| `D3` | `OUT_ALARM_MAIN` | `OUTPUT` | `LOW` = реле «Авария» включено |
| `D4` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `LOW` = реле «Резерв» включено |
| `D8`, `D0`, `A0` | — | — | не используются |
### 2.3 Почему выходы именно на D3 и D4
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
выполнился, иначе реле останется включённым на всё время старта.
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|---|---|---|---|
| `D3` | 0 | подтянут `HIGH` (10к) | выключено ✓ |
| `D4` | 2 | подтянут `HIGH` (10к) | выключено ✓ |
| `D8` | 15 | подтянут **`LOW`** (10к) | **включено** ✗ |
| `D1`,`D2`,`D5`,`D6`,`D7` | 5,4,14,12,13 | не определён до `pinMode` | неопределённо |
Для реле active-**HIGH** вывод был бы обратным: `D8` — лучший пин, `D3`/`D4`
худшие. Логика выходов и выбор пинов связаны жёстко; менять одно без другого
нельзя.
В `setup()` порядок операций тоже важен: `digitalWrite(pin, HIGH)` вызывается
**до** `pinMode(pin, OUTPUT)`, иначе защёлка выхода может кратковременно выдать
`LOW`.
Инверсия уровня локализована в одной функции — единственном месте, где она
существует:
```cpp
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
```
#### Что происходит при перезагрузке на самом деле
Проверено на собранном приборе: **при перезагрузке оба реле кратковременно
моргают.** Признано некритичным и оставлено как есть.
Ранее в этом разделе утверждалось, что реле при загрузке молчат — это оказалось
неверно, и формулировка исправлена.
Наиболее вероятный механизм: оба выхода неизбежно сидят на служебных пинах.
`GPIO2` (`D4`) — это ещё и `UART1 TX`, и на нём во время загрузки появляется
служебный вывод; `GPIO0` (`D3`) — пин выбора режима загрузки, который дёргается
схемой сброса. Пока `setup()` не выполнился, уровнем на них управляет не
прошивка. Точную причину не измеряли: осциллографа в проверке не было.
Часть морганий может приходиться на светодиод релейного модуля, а не на
фактическое переключение контакта: короткий импульс успевает зажечь индикатор,
но не перебросить якорь. Различить можно тестером на контактах реле, если это
когда-нибудь станет важно.
**Почему решение всё равно верное.** Альтернатива — `D8` — не улучшила бы
ситуацию, а ухудшила: он подтянут к `LOW`, и реле было бы уверенно **включено
всю загрузку**, а не моргнуло бы. Кратковременное моргание предпочтительнее
устойчивого ложного сигнала на несколько секунд.
**Если моргание когда-нибудь станет проблемой** (например, сирена успевает
пискнуть): ставится RC-цепочка на вход релейного модуля, чтобы короткие импульсы
не проходили, либо модуль с триггером Шмитта на входе. Прошивку менять не
потребуется.
### 2.4 Прочие особенности ESP8266
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
«Резерв» получает бесплатную индикацию на плате.
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
Кроме того, оба реле кратковременно моргают при любой перезагрузке (см. 2.3).
Косметика, но пугает.
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
цифровых пинов не входит.
* Схема **не fail-safe**: авария включает реле, поэтому обесточенный контроллер
сигнала не даёт. Если нужна отказобезопасность — снимайте нагрузку с
нормально замкнутого (NC) контакта реле, тогда пропажа питания читается как
авария.
### 2.5 Дисплей
SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (константа `OLED_ADDR`).
Если экран не найден — в лог уходит сообщение, прошивка продолжает работать
без дисплея (`oledOK = false`).
### 2.6 Подключение датчиков
Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ).
Замкнут на GND → насос считается работающим. Внешние резисторы не нужны,
используется внутренняя подтяжка.
---
## 3. Зависимости
Устанавливаются через Arduino Library Manager:
* Adafruit SSD1306
* Adafruit GFX Library
* WiFiManager (tzapu)
* ArduinoJson (v7)
Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`,
`WiFiClientSecure`, `LittleFS`, `Wire`.
Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600.
**Flash Size: `2MB (FS:64KB OTA:~992KB)`** — под физические 2 МБ целевой платы.
Именно этот вариант проверен на железе: LittleFS отдаёт 45 056 байт полезного
объёма, чего для `settings.json` (несколько сотен байт) с запасом. Подойдёт и
любой другой `2MB (FS:...)`, если понадобится больше места под файлы.
Два требования к этой настройке, оба обязательные:
* **Размер должен совпадать с физическим чипом.** Выбранные 4 МБ на плате с 2 МБ
уводят и EEPROM, и файловую систему за границу микросхемы — настройки молча
не сохраняются (раздел 6.1).
* **Файловая система должна быть ненулевой.** При `FS:none` сохранять настройки
некуда; прошивка скажет об этом в лог при старте и покажет предупреждение в
веб-панели.
Раскладка для 2 МБ (любой из вариантов `2MB (FS:...)`) кладёт файловую систему
ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа.
После смены `Flash Size` заливайте прошивку заново: меняется заголовок
загрузчика и границы областей. Учтите, что **сохранённые учётные данные Wi-Fi
при этом теряются** и прибор поднимет портал `Pump_Control_Set`.
---
## 4. Логика работы
### 4.1 Антидребезг
`updatePumpState()` для каждого входа:
```
если чтение изменилось -> сбросить таймер lastDebounceTime
если чтение стабильно > 3000 мс -> зафиксировать stableState
```
`debounceDelay = 3000 мс` — длинный намеренно: фильтрует пусковые дребезги
пускателя и кратковременные просадки.
### 4.2 Готовность системы
`systemReady` становится `true` через `debounceDelay + 500 мс` после старта.
До этого момента аварии не формируются и webhook не отправляется — иначе
при включении питания система рапортовала бы ложную аварию.
### 4.3 Основная авария
Условие: **оба** основных насоса стоят (`!p1 && !p2`).
```
bothStopped -> запуск таймера mainAlarmStartTime
выдержка alarmDelaySec (настраивается, по умолчанию 5 с)
-> реле «Авария» включено
-> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно)
любой насос запустился
-> реле «Авария» выключено
-> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно)
```
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг, константа
`debounceDelay`) + `alarmDelaySec`. При значениях по умолчанию ≈ **8 секунд**.
### 4.4 Резервный насос
Логика симметрична основной аварии — та же схема «ожидание, затем реакция»:
```
pE = работает -> запуск таймера emergStartTime
выдержка emergDelaySec (настраивается, по умолчанию 0 с)
-> реле «Резерв» включено
-> webhook EMERGENCY PUMP / STARTED (однократно)
pE = стоит -> реле «Резерв» выключено
-> webhook EMERGENCY PUMP / STOPPED (однократно)
```
При `emergDelaySec = 0` условие `millis() - emergStartTime >= 0` выполняется на
следующей же итерации `loop()`, то есть реакция мгновенная.
### 4.5 Однократность и общая выдержка
Обе выдержки гасят **и реле, и webhook одновременно** — это одна настройка на
канал, а не отдельные таймеры для реле и уведомления.
Флаги `mainAlarmSent` / `emergencyActiveSent` гарантируют отправку ровно
одного webhook на каждый переход состояния.
---
## 5. Webhook
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
```json
{"event":"MAIN PUMPS","state":"ALARM",
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
```
| Поле | Назначение |
|---|---|
| `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
| `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
| `status` | **Человеческая фраза**, та же что в панели |
| `device` | Имя прибора из настроек |
Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
момент отправки — это те же слова, что видит оператор в панели:
| Ситуация | `status` |
|---|---|
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
| Норма | `Подача есть, работает насос 1` |
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
разошлись бы при первой правке.
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
поэтому экранирование в JSON не требуется нигде.
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()`
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
локальную/корпоративную инфраструктуру, но не защищает от MITM.
### 5.1 Бюджет отправки
Отправка остаётся синхронной — BearSSL выполняет TLS-хендшейк блокирующе, и
полностью асинхронного HTTPS на ESP8266 без хрупких сторонних библиотек нет.
Вместо этого блокировка **ограничена по времени** бюджетом
`WEBHOOK_BUDGET_MS = 3000 мс`, который расходуется по этапам:
1. **Разбор URL** (`parseWebhookHost`) — извлекает имя хоста, мгновенно.
2. **DNS**`WiFi.hostByName(host, ip, WEBHOOK_DNS_BUDGET_MS)`, лимит 800 мс.
3. **TCP + TLS + чтение ответа**`http.setTimeout(остаток бюджета)`.
Шаг 2 существует именно ради потолка: `WiFiClientSecureCtx::connect(name, port)`
внутри вызывает `WiFi.hostByName()` **без таймаута** и на мёртвом DNS подвешивает
`loop()` примерно на 10 секунд. Предварительный резолв с лимитом кладёт адрес в
кэш lwIP, после чего внутренний резолв возвращается мгновенно. Соединение
по-прежнему устанавливается по имени, поэтому SNI и заголовок `Host` не ломаются
(важно, если webhook живёт за реверс-прокси).
Шаг 3 опирается на то, что `HTTPClient::connect()` вызывает
`_client->setTimeout(_tcpTimeout)` **до** `_client->connect()`, а хендшейк
BearSSL ограничен тем же `_timeout`. Чтение ответа ограничено отдельно —
собственным циклом `HTTPClient` по `_tcpTimeout`.
Проверять бюджет между фазами внутри `HTTPClient` нельзя, поэтому потолок не
строго 3000 мс. Но провал любой фазы обрывает цепочку, так что лимиты не
складываются. Реальные худшие случаи:
| Сценарий | Задержка |
|---|---|
| Успешная отправка | 300–1500 мс |
| DNS не отвечает | ~800 мс |
| IP не отвечает (чёрная дыра) | ~800 мс + остаток бюджета |
| TLS не поднимается | то же |
| Хендшейк прошёл, ответа нет | ~1500 мс + остаток бюджета ≈ 3.5 с |
До введения бюджета те же сценарии давали 10–15 секунд.
Фактическое время каждой отправки пишется в лог: `[HTTP] Код: 200 (412 мс)`.
---
## 6. Хранение настроек (LittleFS)
Настройки лежат в файловой системе LittleFS, в файле `/settings.json`:
```json
{"webhook":"https://...","device":"Wemos_D1_Pump","alarm_delay":5,"emerg_delay":0}
```
| Поле структуры `AppSettings` | Ключ JSON | По умолчанию |
|---|---|---|
| `webhookUrl` (`char[221]`) | `webhook` | пусто |
| `deviceName` (`char[32]`) | `device` | `Wemos_D1_Pump` |
| `alarmDelaySec` (`uint16`) | `alarm_delay` | `5` |
| `emergDelaySec` (`uint16`) | `emerg_delay` | `0` |
Имена ключей вынесены в константы `KEY_*` и используются и при чтении, и при
записи — рассинхронизация имён между load и save самый частый источник багов в
таком коде, а через константу компилятор их разойтись не даст.
Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без
перезагрузки, и глобальные `String` фрагментировали бы кучу.
### 6.1 История: почему настройки не сохранялись
Реальный случай, разобранный на этом проекте. Симптом: настройки и webhook
пропадают после перезагрузки.
**Корневая причина — несовпадение размера флеша.** В IDE было выбрано
`4MB (FS:2MB OTA:~1019KB)`, тогда как физически на плате **2 МБ**:
| Область | Смещение при настройке 4MB | Внутри чипа 2 МБ (`0x200000`)? |
|---|---|---|
| Сектор EEPROM | `0x3FB000` = 4 173 824 | нет, вдвое дальше конца |
| Начало LittleFS | `0x400000` = 2 097 152 | нет, ровно за границей |
`EEPROM.commit()` возвращал `false`, потому что `spi_flash_erase_sector()`
обращался к несуществующему сектору.
**Лечится настройкой IDE, а не кодом:** любой вариант `2MB (FS:...)` кладёт
файловую систему ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа.
С верной настройкой заработали бы оба механизма хранения.
Что было проверено по пути и оказалось ни при чём: библиотека EEPROM, перекрытие
адресов полей, стирание сектора со стороны `WiFiManager::resetSettings()`
(вызывает только `WiFi.disconnect()`).
**Почему всё-таки перешли на LittleFS**, раз причина была не в EEPROM:
* добавление новой настройки — одна строка, без арифметики адресов и без
версионирования раскладки с миграцией;
* отсутствующий ключ в старом файле не ломает чтение;
* содержимое читается глазами;
* тот же механизм используется в соседних проектах — единообразие.
То есть переход остаётся оправданным по своим качествам, но **проблему решила
настройка `Flash Size`**, а не смена хранилища. Не перепутайте это, если
столкнётесь с похожим симптомом на другой плате.
**Диагностика при старте.** Прошивка печатает геометрию и сама указывает на
несоответствие:
```
[FLASH] по настройке IDE: 4194304 Б, физически: 2097152 Б -> !!! НЕ СОВПАДАЕТ !!!
[FLASH] файловая система: смещение 0x200000, размер 2093056 Б <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!
```
### 6.2 Отказоустойчивость
Загрузка не падает на неполном или повреждённом файле. Порядок в
`settingsBegin()`:
1. `settingsDefaults()` — заполнить структуру значениями по умолчанию;
2. `settingsLoad()` — перезаписать полями из JSON; отсутствующий или битый ключ
**оставляет значение по умолчанию**;
3. если файла нет или JSON не разобрался — `settingsSave()` создаёт его заново.
Благодаря пункту 2 файл, записанный прошлой версией прошивки, не ломается при
добавлении новых полей — старый `/settings.json` просто не содержит нового ключа,
и подставляется значение по умолчанию.
`settingsClamp()` дополнительно приводит значения к допустимым: пустое
`deviceName` и выдержка больше `DELAY_MAX_SEC` заменяются значениями по
умолчанию. Вызывается и после чтения, и перед записью.
Если ФС не смонтировалась, выполняется `LittleFS.format()` и повторная попытка.
При окончательной неудаче взводится `settingsFsReady = false`: прибор продолжает
работать на значениях по умолчанию, но **веб-панель показывает предупреждение**,
что настройки не переживут перезагрузку. Молчаливой потери настроек больше нет.
### 6.3 Ресурс флеша
Запись происходит только по факту изменения — из обработчика `/set_config`, и
только если хотя бы одно поле реально изменилось. Периодической записи нет и
добавлять её не следует.
### 6.4 Как добавить новую настройку
Синхронно правятся четыре места:
1. константа `*_DEFAULT` рядом с остальными значениями по умолчанию;
2. поле в `struct AppSettings` и строка в `settingsDefaults()`;
3. константа `KEY_*`, строка в `settingsLoad()` и строка в `settingsSave()`;
4. поле формы в `getHTML()` и его разбор в обработчике `/set_config`.
Старый `/settings.json` без нового ключа не сломается — подставится значение по
умолчанию.
### 6.5 Что не хранится здесь
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, поэтому сброс
Wi-Fi настройки из `/settings.json` не затрагивает, и наоборот.
Пустой `webhookUrl` — легальное состояние. Пока URL не задан,
`sendPostWebhook()` сразу выходит и пишет в лог
`[HTTP] Webhook URL не задан — отправка пропущена`.
---
## 7. Веб-интерфейс
![Веб-панель контроллера: плашка состояния, лампы датчиков и выходов, раскрытые настройки](docs/img/panel.jpg)
*Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.*
Сервер на порту `80`.
| Маршрут | Метод | Описание |
|---|---|---|
| `/` | `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 Почему страница статическая
Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
**14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
`location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
трогается.
Ответы:
```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"}
```
`/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
### 7.2 Логика статуса и цвета
Цвета взяты по конвенции сигнальных ламп, а не по вкусу:
| Состояние | Цвет | Заголовок | Условие |
|---|---|---|---|
| Запуск | серый | «Запуск» | `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, подменить адрес
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
Контроллер рассчитан на изолированный технологический сегмент.
---
## 8. OLED
Обновление раз в секунду (`DISPLAY_INTERVAL`).
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
менялся и ничего не сообщал.
```
NO SUPPLY <- кегль 2, до 10 символов
────────────────────────
P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
ALARM:ON BACKUP:ON <- состояние выходов
────────────────────────
192.168.1.128
```
| Условие | Слово |
|---|---|
| `!systemReady` | `STARTING` |
| оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
| оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
| работает резерв, основные в порядке | `BACKUP RUN` |
| иначе | `SUPPLY OK` |
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
тащить её во флеш ради четырёх слов не стоит.
Состояние обоих реле теперь выводится — освободились две строки после
объединения датчиков в одну.
---
## 9. Сброс настроек Wi-Fi
Физической кнопки сброса нет — она удалена осознанно. При сохранённых учётных
данных и пропавшей сети `wm.autoConnect()` не подключается и **сам** поднимает
портал `Pump_Control_Set`, поэтому для этого сценария кнопка не нужна.
Остаётся два способа:
* кнопка **«Сбросить Wi-Fi»** на веб-панели (`POST /reset_wifi`) — работает, пока
прибор в сети и панель доступна;
* выключить роутер и перезагрузить прибор — `autoConnect()` не найдёт сеть и
откроет портал.
Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо
перевести в другую сеть»: простая перезагрузка портал не откроет, так как
подключение проходит успешно.
---
## 10. Известные ограничения
* `setInsecure()` — TLS без проверки сертификата.
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета
(см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется
**до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только
обновление OLED, опрос кнопки и веб-панель.
* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются
и не повторяются — webhook теряется, флаги `mainAlarmSent` /
`emergencyActiveSent` выставляются независимо от результата POST.
Ретраи и очередь сознательно не реализованы: контроллер рассчитан на работу
и без отправки событий, автономность — штатный режим, а не отказ.
* **Пауза мониторинга при старте без сохранённой сети.** `wm.autoConnect()`
поднимает портал настройки и блокирует `setup()` в цикле ожидания до
истечения `setConfigPortalTimeout(120)`. Пока `setup()` не завершён, `loop()`
не выполняется: дребезг не считается, аварии не формируются, выходы не
переключаются (они остаются в безопасном `LOW`, выставленном до Wi-Fi).
В установке, где сеть не настраивается никогда, эти 2 минуты повторяются
при каждом включении питания. Принято как есть; при необходимости лечится
`wm.setConfigPortalBlocking(false)` + `wm.process()` в `loop()`с оговоркой,
что в неблокирующем режиме `setConfigPortalTimeout` не применяется и гасить
портал придётся своим таймером.
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
не считается и никак не сигнализируется.
* Webhook URL хранится в `/settings.json` в открытом виде и отдаётся
веб-панелью всем, кто может её открыть. Если URL содержит секретный токен —
доступ к панели равносилен доступу к токену.
---
## 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
"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
"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 бод**. Если заливка падает с
`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

@@ -0,0 +1,185 @@
# Настраиваемые параметры, состояние реле, ревизия распиновки
Дата: 2026-08-07
Статус: согласовано, к реализации
## Задачи
1. Пересмотреть распиновку под доступный набор пинов (A0, D1–D8) и релейные
модули active-LOW.
2. Вынести в веб-панель три параметра: имя устройства, выдержку аварии основных
насосов, выдержку пуска резервного насоса.
3. Показать состояние обоих реле в веб-панели.
## Исходные условия
* Доступны только A0 и D1–D8. Пина `D0`, который использует текущий код, нет.
* A0 — только аналоговый вход: ни цифровым выходом, ни входом с подтяжкой быть
не может, поэтому в бюджет цифровых пинов не входит.
* Реле — готовые модули для Arduino с обвязкой, **active-LOW** (вход на GND =
реле включено). На момент проектирования ещё не подключены, поэтому логику
выходов задаём с нуля.
## 1. Распиновка
Кнопка сброса Wi-Fi удаляется. Обоснование: при сохранённых учётных данных и
пропавшей сети `wm.autoConnect()` не подключается и сам поднимает портал
`Pump_Control_Set` — кнопка для этого не нужна. Для случая «сеть есть, но надо
переехать в другую» остаётся кнопка на веб-панели; физически то же достигается
выключением роутера и перезагрузкой прибора.
Освободившийся D3 закрывает дефицит пинов.
Для реле active-LOW безопасное состояние — пин в **HIGH**, поэтому выходы должны
сидеть на пинах, уже подтянутых к HIGH во время загрузки, пока `setup()` ещё не
выполнился:
| Пин | Уровень при загрузке | Реле active-LOW при загрузке |
|---|---|---|
| D3 | подтянут HIGH (10к) | выключено ✓ |
| D4 | подтянут HIGH (10к) | выключено ✓ |
| D8 | подтянут **LOW** (10к) | включено ✗ ложная авария при каждом старте |
| D1/D2/D5/D6/D7 | не определён до `pinMode` | неопределённо |
Итоговая распиновка:
```
D1 SDA — OLED (без изменений)
D2 SCL — OLED (без изменений)
D5 IN_PUMP_1 (INPUT_PULLUP, LOW = работает)
D6 IN_PUMP_2 (INPUT_PULLUP, LOW = работает)
D7 IN_PUMP_EMERGENCY (INPUT_PULLUP, LOW = работает)
D3 OUT_ALARM_MAIN (выход, active-LOW: LOW = реле включено)
D4 OUT_ALARM_EMERGENCY (выход, active-LOW: LOW = реле включено)
D8, A0, D0 — не используются
```
Особенности, принятые осознанно:
* D4 — встроенный светодиод платы, тоже активен от LOW. Реле «Резерв» получает
бесплатную индикацию на плате.
* D3 — пин выбора режима прошивки. Схема автосброса USB дёргает его вниз при
заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке. Косметика.
* Схема не fail-safe: авария включает реле, поэтому обесточенный контроллер
сигнала не даёт. При необходимости решается на монтаже — снятием нагрузки с
нормально замкнутого (NC) контакта реле.
Инверсия уровня локализуется в одной функции, чтобы её нельзя было применить
дважды или забыть:
```cpp
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
```
В `setup()` `digitalWrite(pin, HIGH)` вызывается **до** `pinMode(pin, OUTPUT)`:
иначе защёлка выхода может кратковременно выдать LOW и щёлкнуть реле.
## 2. Конфигурация в EEPROM
Три новых параметра не влезают в оставшиеся 34 байта (текущая раскладка занимает
222 из 256), поэтому `EEPROM_SIZE` увеличивается до 512.
| Адрес | Размер | Поле | По умолчанию |
|---|---|---|---|
| 0 | 2 | magic `0xA55B` | — |
| 2 | 220 | `webhook_url` | пусто |
| 222 | 32 | `device_name` | `Wemos_D1_Pump` |
| 254 | 2 | `alarm_delay_sec` (uint16) | 5 |
| 256 | 2 | `emerg_delay_sec` (uint16) | 0 |
Значения по умолчанию совпадают с текущим поведением прошивки: `5` — нынешний
`alarmDelay = 5000 мс`, `0` — нынешняя мгновенная реакция на резерв,
`Wemos_D1_Pump` — нынешнее захардкоженное значение поля `device` в теле хука.
Миграция по magic:
* `0xA55B` — читаются все поля.
* `0xA55A` (старая раскладка) — сохраняется `webhook_url`, новые поля получают
значения по умолчанию, magic перезаписывается. Прошитые приборы не теряют
адрес webhook.
* иное — первый старт, все поля по умолчанию.
## 3. Валидация
`device_name` попадает в тело JSON, поэтому кавычка или обратный слэш сломали бы
запрос. Вместо экранирования — ограничение набора символов: разрешены
`A-Z a-z 0-9 _ -`, до 31 символа, остальные символы отбрасываются. Если после
фильтрации строка пуста, поле не меняется. Это же снимает вопрос с выводом
значения в HTML.
Задержки задаются целыми секундами, допустимый диапазон 0…3600. Значение вне
диапазона или нечисловое — поле не меняется.
Пустой `webhook_url` — легальное состояние: отправка событий пропускается,
автономная работа это штатный режим.
## 4. Логика задержек
Одна выдержка на канал, гасит и реле, и вебхук одновременно.
`alarm_delay_sec` заменяет константу `alarmDelay` в существующей паре
`mainAlarmPending` / `mainAlarmStartTime` — поведение основных насосов не
меняется, кроме настраиваемости.
Для резерва добавляется симметричная пара `emergPending` / `emergStartTime`,
которой сейчас нет:
```
pE работает:
выдержка не запущена -> запустить
выдержка истекла -> setRelay(EMERG, true) + вебхук STARTED (однократно)
pE стоит:
setRelay(EMERG, false) + вебхук STOPPED (однократно), выдержка сброшена
```
При `emerg_delay_sec = 0` условие выполняется на следующей же итерации, то есть
нынешнее мгновенное поведение сохраняется.
Полная задержка до сигнала = 3 с (дребезг, константа `debounceDelay`) плюс
настроенная выдержка.
## 5. Веб-интерфейс
`/set_webhook` заменяется на `/set_config` — одна форма на четыре поля (URL, имя
устройства, две задержки). Ломающее изменение для маршрута; внешних потребителей
у него нет.
`/status` расширяется:
```json
{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}
```
Состояние реле берётся из логических переменных `relayMainOn` / `relayEmergOn`,
а не из `digitalRead` — иначе на панели легко получить инвертированную неправду.
Побочный эффект: скрипт панели сравнивает JSON целиком, поэтому страница теперь
перезагружается и при изменении состояния реле.
На панель добавляются:
* ряд из двух индикаторов реле (существующие стили `.spanel`);
* блок настроек с текущими значениями полей.
## 6. Вне объёма
* OLED не меняется — строки для реле там нет без переработки макета.
* Ретраи и очередь вебхуков не добавляются (решение принято ранее).
* Пауза мониторинга до 2 минут при старте без сохранённой сети остаётся как есть.
* Попутно удаляется неиспользуемая переменная `isEmergency` в `getHTML()`
единственное предупреждение компилятора в скетче.
## 7. Проверка
Автоматически:
* Синтаксическая сборка `xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra`
против ядра 3.1.2 — в скетче не должно остаться предупреждений.
* Логика фильтрации имени устройства и разбора чисел — таблицей граничных
случаев.
На железе (остаётся за пользователем):
* при подаче питания оба реле молчат;
* миграция EEPROM сохранила ранее заданный webhook;
* заданные выдержки соблюдаются, при 0 резерв реагирует сразу;
* панель показывает состояние реле, совпадающее с фактическим щелчком.
@@ -0,0 +1,877 @@
/*
============================================================
Контроллер насосов v8 — Wemos D1 Mini
============================================================
Распиновка (реле — модули active-LOW: LOW на входе = реле включено):
D1 — OLED SDA (I2C)
D2 — OLED SCL (I2C)
D5 — IN_PUMP_1 (датчик, INPUT_PULLUP, LOW = работает)
D6 — IN_PUMP_2 (датчик, INPUT_PULLUP, LOW = работает)
D7 — IN_PUMP_EMERGENCY (датчик, INPUT_PULLUP, LOW = работает)
D3 — OUT_ALARM_MAIN (выход, LOW = реле включено)
D4 — OUT_ALARM_EMERGENCY (выход, LOW = реле включено)
D8, D0, A0 — не используются
Почему выходы именно на D3 и D4: для реле active-LOW безопасное состояние —
пин в HIGH, а D3 и D4 подтянуты к HIGH внешними резисторами платы и держат
этот уровень всю загрузку, пока setup() ещё не выполнился. D8 подтянут к LOW
и щёлкал бы реле при каждом включении питания.
Особенности:
- D4 — встроенный светодиод платы (тоже активен от LOW), даёт бесплатную
индикацию реле «Резерв».
- D3 — пин выбора режима прошивки; схема автосброса USB дёргает его вниз,
поэтому реле «Авария» щёлкает при заливке скетча. Косметика.
- Схема не fail-safe: обесточенный контроллер сигнала аварии не даёт.
При необходимости решается на монтаже — нагрузка снимается с нормально
замкнутого (NC) контакта реле.
Настройки хранятся в LittleFS, файл /settings.json.
Библиотеки (установить через Library Manager):
- Adafruit SSD1306
- Adafruit GFX Library
- WiFiManager (tzapu)
- ArduinoJson (v7)
- ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient / LittleFS
(часть ESP8266 core)
ВАЖНО про Flash Size в IDE. Целевая плата имеет 2 МБ флеша, ставить нужно
«2MB (FS:64KB OTA:~992KB)». Выбранный размер обязан совпадать с физическим:
при настройке 4MB на чипе 2MB области EEPROM и файловой системы уходят за
границу микросхемы, и настройки молча не сохраняются. Прошивка печатает
фактический размер в лог при старте строкой [FLASH].
============================================================
*/
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecure.h>
#include <WiFiManager.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <flash_hal.h> // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// --- OLED ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1 // нет отдельного RESET
#define OLED_ADDR 0x3C // обычный адрес SSD1306; попробуй 0x3D если дисплей не найден
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
bool oledOK = false;
// --- КОНФИГУРАЦИЯ ---
const char* config_ap_name = "Pump_Control_Set";
// --- ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ ---
// Webhook намеренно пуст: URL содержит секретный токен, ему не место в исходнике.
const char* WEBHOOK_URL_DEFAULT = "";
const char* DEVICE_NAME_DEFAULT = "Wemos_D1_Pump";
const uint16_t ALARM_DELAY_DEFAULT_SEC = 5; // выдержка аварии основных насосов
const uint16_t EMERG_DELAY_DEFAULT_SEC = 0; // выдержка пуска резерва (0 = сразу)
const uint16_t DELAY_MAX_SEC = 3600;
const int WEBHOOK_MAX_LEN = 220;
const int DEVICE_MAX_LEN = 31;
// --- ХРАНИЛИЩЕ НАСТРОЕК ---
// LittleFS, а не EEPROM. Важно не перепутать причины: EEPROM переставала
// сохранять из-за неверного Flash Size в IDE (см. шапку файла), а не потому,
// что механизм плох — с верной настройкой работали бы оба. LittleFS выбран
// за то, что новое поле добавляется одной строкой, без арифметики адресов и
// версионирования раскладки, а старый файл не ломается при этом.
// Разбор случая — в README, раздел 6.1.
const char* SETTINGS_PATH = "/settings.json";
// Имена ключей вынесены в константы намеренно: рассинхронизация имён между
// чтением и записью — самый частый источник багов в таком коде, а так
// компилятор не даст им разойтись.
const char* KEY_WEBHOOK = "webhook";
const char* KEY_DEVICE = "device";
const char* KEY_ALARM_DELAY = "alarm_delay";
const char* KEY_EMERG_DELAY = "emerg_delay";
// Строки хранятся как char[], а не String: прибор работает месяцами без
// перезагрузки, и глобальные String фрагментируют кучу.
struct AppSettings {
char webhookUrl[WEBHOOK_MAX_LEN + 1];
char deviceName[DEVICE_MAX_LEN + 1];
uint16_t alarmDelaySec;
uint16_t emergDelaySec;
};
AppSettings settings;
bool settingsFsReady = false; // false = ФС недоступна, настройки не переживут перезагрузку
// --- ПИНЫ ---
const int IN_PUMP_1 = D5;
const int IN_PUMP_2 = D6;
const int IN_PUMP_EMERGENCY = D7;
const int OUT_ALARM_MAIN = D3; // реле active-LOW
const int OUT_ALARM_EMERGENCY = D4; // реле active-LOW
// Логическое состояние реле. Держим отдельно от уровня на пине: выводить в
// панель digitalRead() с инверсной логикой — верный способ показать неправду.
bool relayMainOn = false;
bool relayEmergOn = false;
ESP8266WebServer server(80);
// --- СТРУКТУРА НАСОСА ---
typedef struct {
int pin;
String name;
bool stableState;
bool lastReading;
unsigned long lastDebounceTime;
} Pump;
Pump p1 = {IN_PUMP_1, "PUMP_1", false, true, 0};
Pump p2 = {IN_PUMP_2, "PUMP_2", false, true, 0};
Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0};
const unsigned long debounceDelay = 3000;
// --- БЮДЖЕТ ОТПРАВКИ WEBHOOK ---
// Полностью неблокирующий HTTPS на ESP8266 невозможен: BearSSL делает хендшейк
// синхронно. Поэтому блокировка не убирается, а ограничивается по времени.
// HTTPClient не позволяет проверять бюджет между своими фазами (TCP connect /
// TLS / чтение ответа), но неудачная фаза обрывает цепочку, поэтому лимиты
// не складываются. Худший реальный случай — успешный хендшейк плюс зависшее
// чтение ответа, около 3.5 с вместо прежних 10-15 с.
const unsigned long WEBHOOK_BUDGET_MS = 3000; // общий бюджет на отправку
const unsigned long WEBHOOK_DNS_BUDGET_MS = 800; // из него — на резолв имени
bool mainAlarmSent = false;
bool emergencyActiveSent = false;
bool systemReady = false;
unsigned long mainAlarmStartTime = 0;
bool mainAlarmPending = false;
unsigned long emergStartTime = 0;
bool emergPending = false;
// Для обновления дисплея не чаще раза в секунду
unsigned long lastDisplayUpdate = 0;
const unsigned long DISPLAY_INTERVAL = 1000;
// ============================================================
// НАСТРОЙКИ (LittleFS + JSON)
// ============================================================
void settingsDefaults() {
strlcpy(settings.webhookUrl, WEBHOOK_URL_DEFAULT, sizeof(settings.webhookUrl));
strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
settings.alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
settings.emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
}
// Приводит значения к допустимым. Нужно и после чтения файла, и после
// миграции: мусор в файле не должен превращаться в неверную выдержку.
static void settingsClamp() {
settings.webhookUrl[WEBHOOK_MAX_LEN] = '\0';
settings.deviceName[DEVICE_MAX_LEN] = '\0';
if (settings.deviceName[0] == '\0') {
strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
}
if (settings.alarmDelaySec > DELAY_MAX_SEC) settings.alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
if (settings.emergDelaySec > DELAY_MAX_SEC) settings.emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
}
// Загрузка отказоустойчива: отсутствующий или битый ключ оставляет значение,
// уже проставленное settingsDefaults(). Благодаря этому файл, записанный
// прошлой версией прошивки, не ломается при добавлении новых полей.
static bool settingsLoad() {
File f = LittleFS.open(SETTINGS_PATH, "r");
if (!f) {
Serial.println(F("[SET] Файл настроек не найден"));
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.printf("[SET] ОШИБКА разбора JSON: %s\n", err.c_str());
return false;
}
const char* v;
v = doc[KEY_WEBHOOK];
if (v) strlcpy(settings.webhookUrl, v, sizeof(settings.webhookUrl));
v = doc[KEY_DEVICE];
if (v) strlcpy(settings.deviceName, v, sizeof(settings.deviceName));
settings.alarmDelaySec = doc[KEY_ALARM_DELAY] | settings.alarmDelaySec;
settings.emergDelaySec = doc[KEY_EMERG_DELAY] | settings.emergDelaySec;
settingsClamp();
Serial.printf("[SET] Загружено: device=%s, авария=%u с, резерв=%u с\n",
settings.deviceName, settings.alarmDelaySec, settings.emergDelaySec);
Serial.println("[SET] URL: " + String(settings.webhookUrl[0] ? settings.webhookUrl : "(не задан)"));
return true;
}
// Перезаписывает файл целиком. true только если запись реально удалась.
bool settingsSave() {
if (!settingsFsReady) {
Serial.println(F("[SET] ОШИБКА: файловая система недоступна, сохранять некуда"));
return false;
}
settingsClamp();
JsonDocument doc;
doc[KEY_WEBHOOK] = settings.webhookUrl;
doc[KEY_DEVICE] = settings.deviceName;
doc[KEY_ALARM_DELAY] = settings.alarmDelaySec;
doc[KEY_EMERG_DELAY] = settings.emergDelaySec;
File f = LittleFS.open(SETTINGS_PATH, "w");
if (!f) {
Serial.println(F("[SET] ОШИБКА: не удалось открыть файл на запись"));
return false;
}
size_t written = serializeJson(doc, f);
f.close();
if (written == 0) {
Serial.println(F("[SET] ОШИБКА: в файл записано 0 байт"));
return false;
}
Serial.printf("[SET] Сохранено (%u Б): device=%s, авария=%u с, резерв=%u с\n",
(unsigned)written, settings.deviceName,
settings.alarmDelaySec, settings.emergDelaySec);
Serial.println("[SET] URL: " + String(settings.webhookUrl[0] ? settings.webhookUrl : "(не задан)"));
return true;
}
// Монтирует ФС и заполняет settings. Вызывать первым из всего, что работает
// с LittleFS: повторный LittleFS.begin() в других местах не нужен.
void settingsBegin() {
settingsDefaults();
if (!LittleFS.begin()) {
Serial.println(F("[FS] Не смонтирована, форматирую..."));
if (!LittleFS.format() || !LittleFS.begin()) {
Serial.println(F("[FS] ОШИБКА: файловая система недоступна."));
Serial.println(F("[FS] Прибор работает, но настройки не переживут перезагрузку."));
settingsFsReady = false;
return;
}
Serial.println(F("[FS] Отформатирована успешно"));
}
settingsFsReady = true;
FSInfo info;
if (LittleFS.info(info)) {
Serial.printf("[FS] Смонтирована: всего %u Б, занято %u Б\n",
(unsigned)info.totalBytes, (unsigned)info.usedBytes);
}
if (!settingsLoad()) {
Serial.println(F("[SET] Создаю файл со значениями по умолчанию"));
settingsSave();
}
}
// ============================================================
// OLED - макет экрана 128x64
// ============================================================
/*
Верхняя строка отдана главному ответу кеглем 2 (12x16 px) - читается от
двери. Прежний постоянный заголовок "PUMP CONTROLLER v8" её занимал, но
ничего не сообщал. При аварии строка мигает инверсией раз в 500 мс.
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не
содержит, а тащить её во флеш ради четырёх слов не стоит.
+--------------------------+
| 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
*/
// ============================================================
// Реле аварий (модули active-LOW: LOW = включено)
// ============================================================
// Единственное место, где живёт инверсия уровня. Логическое состояние
// дублируется в переменную, чтобы веб-панель показывала смысл, а не уровень.
void setRelay(int pin, bool on) {
digitalWrite(pin, on ? LOW : HIGH);
if (pin == OUT_ALARM_MAIN) relayMainOn = on;
else if (pin == OUT_ALARM_EMERGENCY) relayEmergOn = 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.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE);
// --- Датчики: три поля в одной строке вместо трёх строк ---
display.setCursor(0, 25);
display.print(F("P1:"));
display.print(p1.stableState ? F("RUN") : F("OFF"));
display.print(F(" P2:"));
display.print(p2.stableState ? F("RUN") : F("OFF"));
display.print(F(" BK:"));
display.print(pE.stableState ? F("RUN") : F("OFF"));
// --- Выходы на реле ---
display.setCursor(0, 37);
display.print(F("ALARM:"));
display.print(relayMainOn ? F("ON ") : F("OFF"));
display.print(F(" BACKUP:"));
display.print(relayEmergOn ? F("ON") : F("OFF"));
display.drawFastHLine(0, 49, SCREEN_WIDTH, SSD1306_WHITE);
// --- Адрес в сети ---
display.setCursor(0, 54);
if (WiFi.status() == WL_CONNECTED) display.print(WiFi.localIP().toString());
else display.print(F("no network"));
display.display();
}
// ============================================================
// HTTPS POST
// ============================================================
// Вытаскивает имя хоста из URL вида https://host[:port][/path] —
// оно нужно, чтобы разрешить адрес заранее с собственным таймаутом.
// Порт отбрасывается: соединение всё равно устанавливает HTTPClient по URL.
// Форма user@host не поддерживается — в webhook-адресах не встречается.
static bool parseWebhookHost(const char* url, String &host) {
String u(url);
int schemeEnd = u.indexOf("://");
if (schemeEnd < 0) return false;
String scheme = u.substring(0, schemeEnd);
scheme.toLowerCase();
if (scheme != "https" && scheme != "http") return false;
// Границы authority: до первого '/', '?' или '#'
int authStart = schemeEnd + 3;
int authEnd = u.length();
for (int i = authStart; i < (int)u.length(); i++) {
char c = u[i];
if (c == '/' || c == '?' || c == '#') { authEnd = i; break; }
}
String auth = u.substring(authStart, authEnd);
int colon = auth.indexOf(':');
if (colon >= 0) auth = auth.substring(0, colon);
if (auth.length() == 0) return false;
host = auth;
return true;
}
void sendPostWebhook(String eventName, String state) {
if (settings.webhookUrl[0] == '\0') {
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
return;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println(F("[HTTP] Нет Wi-Fi"));
return;
}
const unsigned long started = millis();
String host;
if (!parseWebhookHost(settings.webhookUrl, host)) {
Serial.println("[HTTP] Не удалось разобрать URL: " + String(settings.webhookUrl));
return;
}
// --- Шаг 1: DNS с явным лимитом ---
// WiFiClientSecure::connect(name, port) резолвит имя без таймаута и способен
// подвесить loop() примерно на 10 с. Разрешаем сами, с бюджетом; результат
// ложится в кэш lwIP, поэтому внутренний резолв дальше уже мгновенный
// (и SNI по-прежнему передаётся, так как HTTPClient коннектится по имени).
IPAddress ip;
if (!WiFi.hostByName(host.c_str(), ip, WEBHOOK_DNS_BUDGET_MS)) {
Serial.printf("[HTTP] DNS не ответил за %lu мс: %s\n",
WEBHOOK_DNS_BUDGET_MS, host.c_str());
return;
}
// --- Шаг 2: остаток бюджета на TCP + TLS + чтение ответа ---
unsigned long elapsed = millis() - started;
if (elapsed >= WEBHOOK_BUDGET_MS) {
Serial.println(F("[HTTP] Бюджет исчерпан на резолве DNS"));
return;
}
const uint16_t phaseBudget = (uint16_t)(WEBHOOK_BUDGET_MS - elapsed);
yield(); // отдать время стеку Wi-Fi перед блокирующей фазой
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(phaseBudget);
HTTPClient http;
http.setTimeout(phaseBudget); // ограничивает и connect+TLS, и чтение ответа
http.setReuse(false); // не держать TLS-сокет после отправки
Serial.print(F("[HTTP] POST: "));
Serial.println(settings.webhookUrl);
if (!http.begin(client, settings.webhookUrl)) {
Serial.println(F("[HTTP] begin() не удался"));
return;
}
http.addHeader("Content-Type", "application/json");
// 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);
if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started);
else Serial.printf("[HTTP] Ошибка: %s (%lu мс)\n",
http.errorToString(code).c_str(), millis() - started);
http.end();
}
// ============================================================
// Валидация настроек из веб-формы
// ============================================================
// Оставляет только [A-Za-z0-9_-] и обрезает до DEVICE_MAX_LEN.
// Такой набор символов безопасен и в теле JSON, и в HTML, поэтому
// экранирование нигде не нужно. Пустой результат = поле не менять.
static String sanitizeDeviceName(const String &raw) {
String out;
out.reserve(DEVICE_MAX_LEN);
for (unsigned int i = 0; i < raw.length() && (int)out.length() < DEVICE_MAX_LEN; i++) {
char c = raw[i];
bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-';
if (ok) out += c;
}
return out;
}
// Разбирает выдержку в секундах. Принимает только строку целиком из цифр
// в диапазоне 0..DELAY_MAX_SEC. false = поле не менять.
static bool parseDelaySec(const String &raw, uint16_t &out) {
String s(raw);
s.trim();
if (s.length() == 0 || s.length() > 4) return false;
for (unsigned int i = 0; i < s.length(); i++) {
if (s[i] < '0' || s[i] > '9') return false;
}
long v = s.toInt();
if (v < 0 || v > (long)DELAY_MAX_SEC) return false;
out = (uint16_t)v;
return true;
}
// ============================================================
// Дребезг + обновление стабильного состояния
// ============================================================
void updatePumpState(Pump &p) {
bool currentReading = digitalRead(p.pin);
if (currentReading != p.lastReading) {
p.lastDebounceTime = millis();
}
if ((millis() - p.lastDebounceTime) > debounceDelay) {
bool logicalState = (currentReading == LOW);
if (logicalState != p.stableState) {
p.stableState = logicalState;
Serial.printf("[SENSOR] %s -> %s\n", p.name.c_str(), p.stableState ? "РАБОТАЕТ" : "СТОИТ");
}
}
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());
}
// ============================================================
// ВЕБ-ПАНЕЛЬ
// ============================================================
// Разметка вынесена в web_page.h намеренно, а не для красоты: генератор
// прототипов Arduino не понимает сырые строковые литералы и принимает
// "function draw(d){" внутри JS за определение функции C++, после чего
// вставляет для него прототип и сборка падает. Заголовочные файлы он не
// сканирует. Обычный g++ такую ошибку не показывает — она видна только
// при сборке через arduino-cli или IDE.
#include "web_page.h"
;
// ============================================================
// SETUP
// ============================================================
void setup() {
Serial.begin(115200);
delay(500);
Serial.println(F("\n\n--- СТАРТ КОНТРОЛЛЕРА v8 ---"));
// I2C: SDA=D1, SCL=D2 (по умолчанию для Wemos D1 Mini)
Wire.begin(D1, D2);
// Инит OLED
if (display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
oledOK = true;
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(20, 20);
display.print(F("PUMP CTRL v8"));
display.setCursor(20, 34);
display.print(F("Starting..."));
display.display();
Serial.println(F("[OLED] Дисплей OK"));
} else {
Serial.println(F("[OLED] Дисплей не найден! Проверь адрес (0x3C/0x3D) и провода."));
}
// Диагностика флеша. Если «размер по настройке IDE» больше физического, то
// сектор EEPROM оказывается за пределами микросхемы и запись молча теряется —
// выглядит это ровно как «настройки не сохраняются».
uint32_t flashIde = ESP.getFlashChipSize();
uint32_t flashReal = ESP.getFlashChipRealSize();
Serial.printf("[FLASH] по настройке IDE: %u Б, физически: %u Б -> %s\n",
flashIde, flashReal,
flashIde == flashReal ? "совпадает" : "!!! НЕ СОВПАДАЕТ !!!");
Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n",
ESP.getSketchSize(), ESP.getFreeSketchSpace());
// Где лежит файловая система. Если область выходит за размер микросхемы,
// LittleFS не смонтируется и настройки сохранять будет некуда.
Serial.printf("[FLASH] файловая система: смещение 0x%06X, размер %u Б%s\n",
FS_PHYS_ADDR, FS_PHYS_SIZE,
FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal
? "" : " <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!");
if (flashIde != flashReal) {
Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует"));
Serial.println(F("[FLASH] микросхеме. Выберите верный в Tools -> Flash Size,"));
Serial.println(F("[FLASH] иначе настройки сохраняться не будут."));
}
if (FS_PHYS_SIZE == 0) {
Serial.println(F("[FLASH] ВНИМАНИЕ: в Tools -> Flash Size выбрано FS:none."));
Serial.println(F("[FLASH] Нужен вариант с ненулевой файловой системой."));
}
settingsBegin();
pinMode(p1.pin, INPUT_PULLUP);
pinMode(p2.pin, INPUT_PULLUP);
pinMode(pE.pin, INPUT_PULLUP);
// Порядок важен: сначала задаём HIGH (реле active-LOW выключено), только
// потом переводим пин в OUTPUT. Наоборот защёлка выхода может кратковременно
// выдать LOW и щёлкнуть реле.
digitalWrite(OUT_ALARM_MAIN, HIGH);
digitalWrite(OUT_ALARM_EMERGENCY, HIGH);
pinMode(OUT_ALARM_MAIN, OUTPUT);
pinMode(OUT_ALARM_EMERGENCY, OUTPUT);
setRelay(OUT_ALARM_MAIN, false);
setRelay(OUT_ALARM_EMERGENCY, false);
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
// Web-сервер
// Страница отдаётся прямо из флеша, минуя кучу.
server.on("/", HTTP_GET, []() {
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 += "\"rdy\":" + String(systemReady ? "1" : "0") + ",";
// Формулировки приходят готовыми, а не собираются в браузере: та же
// фраза уходит в webhook, и второй набор строк в JS неизбежно бы разошёлся.
json += "\"k\":\"" + String(statusKind()) + "\",";
json += "\"w\":\"" + String(statusWord()) + "\",";
json += "\"s\":\"" + String(statusSub()) + "\"";
json += "}";
server.send(200, "application/json", json);
});
// Настройки. Забираются один раз при загрузке страницы.
// Webhook отдаётся только именем хоста: полный URL содержит секретный токен,
// и выводить его на экран, который видно всем в помещении, незачем.
server.on("/config", HTTP_GET, []() {
String host;
if (!parseWebhookHost(settings.webhookUrl, host)) host = "";
String json = "{";
json += "\"device\":\"" + String(settings.deviceName) + "\",";
json += "\"alarm\":" + String(settings.alarmDelaySec) + ",";
json += "\"emerg\":" + String(settings.emergDelaySec) + ",";
json += "\"host\":\"" + host + "\",";
json += "\"fs\":" + String(settingsFsReady ? "1" : "0") + ",";
json += "\"ip\":\"" + (WiFi.status() == WL_CONNECTED
? WiFi.localIP().toString() : String("нет сети")) + "\"";
json += "}";
server.send(200, "application/json", json);
});
server.on("/reset_wifi", HTTP_POST, []() {
server.send(200, "text/plain", "WiFi Reset. Rebooting...");
delay(2000);
WiFiManager wm; wm.resetSettings(); ESP.restart();
});
// Настройки одной формой. Пустое или неверное поле означает «не менять» —
// так частично заполненная форма не сбрасывает остальные параметры.
server.on("/set_config", HTTP_POST, []() {
bool changed = false;
if (server.hasArg("url")) {
String v = server.arg("url"); v.trim();
if (v.length() > 0 && v.length() <= WEBHOOK_MAX_LEN) {
v.toCharArray(settings.webhookUrl, WEBHOOK_MAX_LEN + 1);
changed = true;
}
}
if (server.hasArg("device")) {
String v = sanitizeDeviceName(server.arg("device"));
if (v.length() > 0 && v != String(settings.deviceName)) {
v.toCharArray(settings.deviceName, DEVICE_MAX_LEN + 1);
changed = true;
}
}
if (server.hasArg("alarm_delay")) {
uint16_t v;
if (parseDelaySec(server.arg("alarm_delay"), v) && v != settings.alarmDelaySec) {
settings.alarmDelaySec = v;
changed = true;
}
}
if (server.hasArg("emerg_delay")) {
uint16_t v;
if (parseDelaySec(server.arg("emerg_delay"), v) && v != settings.emergDelaySec) {
settings.emergDelaySec = v;
changed = true;
}
}
if (changed) settingsSave();
else Serial.println(F("[SET] Изменений нет, запись пропущена"));
server.sendHeader("Location", "/");
server.send(303);
});
// WiFiManager с заставкой на OLED
if (oledOK) {
display.clearDisplay();
display.setCursor(0, 10);
display.print(F("WiFi: connecting..."));
display.setCursor(0, 24);
display.print(F("AP: "));
display.print(config_ap_name);
display.setCursor(0, 38);
display.print(F("Connect & open:"));
display.setCursor(0, 48);
display.print(F("192.168.4.1"));
display.display();
}
WiFiManager wm;
wm.setConfigPortalTimeout(120);
if (wm.autoConnect(config_ap_name)) {
server.begin();
Serial.println("[WIFI] Подключено. IP: " + WiFi.localIP().toString());
if (oledOK) {
display.clearDisplay();
display.setCursor(0, 16);
display.print(F("WiFi: CONNECTED"));
display.setCursor(0, 30);
display.print(F("IP: "));
display.print(WiFi.localIP().toString());
display.display();
delay(2000);
}
} else {
Serial.println(F("[WIFI] Работа в автономном режиме."));
if (oledOK) {
display.clearDisplay();
display.setCursor(0, 16);
display.print(F("WiFi: NO CONNECTION"));
display.setCursor(0, 30);
display.print(F("Offline mode active"));
display.display();
delay(2000);
}
}
Serial.println(F("[SYSTEM] Инициализация завершена."));
}
// ============================================================
// LOOP
// ============================================================
void loop() {
server.handleClient();
updatePumpState(p1);
updatePumpState(p2);
updatePumpState(pE);
if (!systemReady) {
if (millis() > debounceDelay + 500) {
systemReady = true;
Serial.println(F("[SYSTEM] Автоматика готова."));
}
// Обновляем дисплей даже во время инита
if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) {
updateDisplay();
lastDisplayUpdate = millis();
}
return;
}
// --- Основная авария ---
// Выдержка одна на реле и вебхук: при settings.alarmDelaySec = 0 условие выполняется
// на следующей же итерации, то есть реакция мгновенная.
bool bothStopped = (!p1.stableState && !p2.stableState);
if (bothStopped) {
if (!mainAlarmPending) {
mainAlarmPending = true;
mainAlarmStartTime = millis();
} else if (millis() - mainAlarmStartTime >= (unsigned long)settings.alarmDelaySec * 1000UL) {
setRelay(OUT_ALARM_MAIN, true);
if (!mainAlarmSent) {
Serial.println(F("[ALERT] Авария основных насосов"));
sendPostWebhook("MAIN PUMPS", "ALARM");
mainAlarmSent = true;
}
}
} else {
setRelay(OUT_ALARM_MAIN, false);
mainAlarmPending = false;
if (mainAlarmSent) {
Serial.println(F("[INFO] Восстановление: основные насосы OK"));
sendPostWebhook("MAIN PUMPS", "OK");
mainAlarmSent = false;
}
}
// --- Резервный насос ---
if (pE.stableState) {
if (!emergPending) {
emergPending = true;
emergStartTime = millis();
} else if (millis() - emergStartTime >= (unsigned long)settings.emergDelaySec * 1000UL) {
setRelay(OUT_ALARM_EMERGENCY, true);
if (!emergencyActiveSent) {
Serial.println(F("[WARN] Запущен экстренный насос"));
sendPostWebhook("EMERGENCY PUMP", "STARTED");
emergencyActiveSent = true;
}
}
} else {
setRelay(OUT_ALARM_EMERGENCY, false);
emergPending = false;
if (emergencyActiveSent) {
Serial.println(F("[INFO] Резерв остановлен"));
sendPostWebhook("EMERGENCY PUMP", "STOPPED");
emergencyActiveSent = false;
}
}
// --- Обновление дисплея раз в секунду ---
if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) {
updateDisplay();
lastDisplayUpdate = millis();
}
delay(10);
}
+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>)====="