Files
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

41 KiB
Raw Permalink Blame History

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.

Инверсия уровня локализована в одной функции — единственном месте, где она существует:

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:

{"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. DNSWiFi.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 мс. Но провал любой фазы обрывает цепочку, так что лимиты не складываются. Реальные худшие случаи:

Сценарий Задержка
Успешная отправка 3001500 мс
DNS не отвечает ~800 мс
IP не отвечает (чёрная дыра) ~800 мс + остаток бюджета
TLS не поднимается то же
Хендшейк прошёл, ответа нет ~1500 мс + остаток бюджета ≈ 3.5 с

До введения бюджета те же сценарии давали 10–15 секунд.

Фактическое время каждой отправки пишется в лог: [HTTP] Код: 200 (412 мс).


6. Хранение настроек (LittleFS)

Настройки лежат в файловой системе LittleFS, в файле /settings.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. Веб-интерфейс

Веб-панель контроллера: плашка состояния, лампы датчиков и выходов, раскрытые настройки

Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.

Сервер на порту 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, и форма не трогается.

Ответы:

/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:

"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 --warnings all .
"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 не показывает, на реальной сборке останавливает всё.