Files
Arduino/pump_controller_8_2_OLED_DONE
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
..

Pump Controller v8.2 (OLED)

Контроллер мониторинга насосной станции на Wemos D1 Mini (ESP8266).

Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в локальной сети и отправляет события на HTTPS-webhook (n8n).


1. Состав репозитория

Файл Назначение
pump_controller_8_2_OLED_DONE.ino Весь прошивочный код (single-file Arduino sketch)
README.md Техническая документация (этот файл)
MANUAL.md Руководство пользователя / монтажника
docs/specs/ Согласованные проектные решения по крупным изменениям

2. Аппаратная часть

2.1 Плата

Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или 3.3 В на пин 3V3.

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); }

2.4 Прочие особенности ESP8266

  • D4 (GPIO2) — встроенный светодиод платы, тоже активен от LOW. Реле «Резерв» получает бесплатную индикацию на плате.
  • D3 (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке. Косметика, но пугает.
  • 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 обязательно с файловой системой — например 4MB (FS:2MB OTA:~1019KB). При FS:none настройки сохранять некуда; прошивка сообщит об этом в лог при старте и покажет предупреждение в веб-панели.


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","status":"ALARM","device":"Wemos_D1_Pump"}
event status
MAIN PUMPS ALARM / OK
EMERGENCY PUMP STARTED / STOPPED

Поле device берётся из настройки settings.deviceName (задаётся через веб-панель, по умолчанию Wemos_D1_Pump). Значение отфильтровано при вводе до [A-Za-z0-9_-], поэтому экранирование в 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 Почему не EEPROM

Изначально настройки хранились в эмулируемой EEPROM. На целевой плате это не работает: EEPROM.commit() возвращает false, то есть падает сам вызов SDK (spi_flash_erase_sector либо spi_flash_write), и настройки молча теряются.

Проверкой исходников исключены: ошибка в библиотеке EEPROM, перекрытие адресов, стирание сектора со стороны WiFiManager::resetSettings(). Физический размер флеша тоже ни при чём — LittleFS на том же чипе работает.

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

Практическое следствие: в IDE обязателен вариант Flash Size с ненулевой файловой системой. При FS:none сохранять настройки будет некуда, и прошивка скажет об этом в лог при старте.

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-панель мониторинга
/status GET JSON {"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}
/reset_wifi POST Стереть настройки Wi-Fi и перезагрузиться
/set_config POST Параметры url, device, alarm_delay, emerg_delay

7.1 /set_config

Одна форма на все настройки. Пустое или неверное поле означает «не менять» — так частично заполненная форма не обнуляет остальные параметры. Запись в файл выполняется только если что-то реально изменилось.

Параметр Валидация При отказе
url 1…220 символов поле не меняется
device фильтр до [A-Za-z0-9_-], ≤31 симв.; пусто после фильтрации → отказ поле не меняется
alarm_delay строка целиком из цифр, 0…3600 поле не меняется
emerg_delay строка целиком из цифр, 0…3600 поле не меняется

Фильтрация device вместо экранирования выбрана осознанно: разрешённый набор символов безопасен и в теле JSON, и в HTML, поэтому экранирование не нужно нигде.

7.2 Состояние реле

rMain и rEmg в /status — это логическое состояние реле из переменных relayMainOn / relayEmergOn, а не digitalRead() с пина. Так сделано намеренно: выходы инверсные, и чтение уровня с пина показало бы на панели обратное действительности.

7.3 Автообновление

Панель опрашивает /status каждые 2 секунды и перезагружает страницу при любом изменении JSON. Поскольку состояние реле теперь входит в ответ, страница обновляется и при срабатывании реле. Шрифты подгружаются с Google Fonts — при отсутствии интернета у клиента интерфейс отрисуется системным моноширинным шрифтом.

Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить webhook и изменить выдержки. Контроллер рассчитан на изолированный технологический сегмент.


8. OLED

Обновление раз в секунду (DISPLAY_INTERVAL).

  PUMP CONTROLLER v8
────────────────────────
PUMP 1 : RUNNING
PUMP 2 : STOPPED
BACKUP : STANDBY
────────────────────────
192.168.1.42
STATUS: OK

Строка статуса:

Условие Текст
!systemReady INIT...
оба основных стоят !! ALARM: NO PUMPS !! (инверсия, мигание 500 мс)
работает резерв WARN: BACKUP RUNNING
иначе STATUS: OK

Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны только в веб-панели и, для «Резерва», по встроенному светодиоду платы на D4.


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. Сборка

arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino

Монитор порта: 115200 бод.