diff --git a/.gitignore b/.gitignore index 546504d..31aac1a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 380545a..ad21e50 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/pump_controller_8_2_OLED_DONE/.gitignore b/pump_controller_8_2_OLED_DONE/.gitignore new file mode 100644 index 0000000..e28c988 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/.gitignore @@ -0,0 +1,9 @@ +build/ +.build/ +*.bin +*.elf +*.hex +*.map +.vscode/ +.DS_Store +Thumbs.db diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md new file mode 100644 index 0000000..82701b7 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -0,0 +1,286 @@ +# Контроллер насосов 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 во время загрузки платы, поэтому реле молчат при включении питания. + +Оговорки, которые лучше знать заранее: + +* Реле «Авария» **щёлкает при каждой заливке прошивки** — схема автосброса USB + дёргает `D3`. Это нормально. +* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для + проверки монтажа. +* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет. + Если это критично — снимайте нагрузку с **нормально замкнутого (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. Экран + +``` + PUMP CONTROLLER v8 +──────────────────────── +PUMP 1 : RUNNING +PUMP 2 : STOPPED +BACKUP : STANDBY +──────────────────────── +192.168.1.42 +STATUS: OK +``` + +| Надпись | Значение | +|---|---| +| `PUMP 1/2 : RUNNING` | Насос работает | +| `PUMP 1/2 : STOPPED` | Насос остановлен | +| `BACKUP : ACTIVE` | Резервный насос запущен | +| `BACKUP : STANDBY` | Резерв в ожидании | +| `WiFi: NO CONNECTION` | Нет связи с сетью | +| `INIT...` | Идёт запуск (первые ~4 секунды) | +| `STATUS: OK` | Норма | +| `WARN: BACKUP RUNNING` | Работает резерв — требуется внимание | +| `!! ALARM: NO PUMPS !!` (мигает) | **Авария: подача воды прекращена** | + +--- + +## 5. Панель в браузере + +Откройте `http://` в любом браузере в той же сети. + +На странице: + +* три карточки насосов с индикаторами; +* панель состояния системы (`SYS_OK` или `CRITICAL`); +* **состояние обоих реле** — «Включено» / «Выключено»; +* кнопка **Сбросить Wi-Fi**; +* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки. + +Страница обновляется автоматически при изменении состояния насосов или реле. + +### 5.1 Состояние реле + +Два индикатора показывают, что прибор реально подал на выход: + +| Индикатор | Значение | +|---|---| +| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан | +| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает | + +Полезно при пусконаладке: если реле показано «Включено», а нагрузка не +сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке. + +### 5.2 Настройки + +Все настройки в одной форме. **Пустое поле означает «не менять»** — можно +поправить одну выдержку, не трогая остальное. Неверное значение так же +игнорируется, поэтому опечатка ничего не испортит. + +| Поле | Что задаёт | Допустимо | По умолчанию | +|---|---|---|---| +| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан | +| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` | +| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` | +| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) | + +Настройки сохраняются в файл во внутренней памяти прибора, переживают +отключение питания и сброс Wi-Fi. + +> Если панель показывает красное предупреждение «Файловая система недоступна» +> или настройки пропадают после перезагрузки — причина в настройке `Flash Size` +> при прошивке. Она обязана совпадать с фактическим размером памяти платы: +> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с +> 2 МБ приводит ровно к такому симптому. + +> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут +> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки. +> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`. + +**На новом приборе адрес уведомлений не задан** — панель покажет +`не задан - уведомления отключены`, сообщения отправляться не будут, всё +остальное работает. + +--- + +## 6. Уведомления на сервер + +При изменении ситуации прибор отправляет сообщение на настроенный адрес: + +| Событие | Что означает | +|---|---| +| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят | +| `MAIN PUMPS` / `OK` | Работа восстановлена | +| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос | +| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился | + +Уведомления отправляются один раз на каждое изменение, а не повторяются. +Если в момент события связи не было — уведомление **не будет** отправлено +позже, оно теряется. + +На отправку отводится не больше **3 секунд**. Если сервер недоступен или +отвечает медленно, прибор прекращает попытку и продолжает работу: экран и +кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не +дожидаясь сервера. + +--- + +## 7. Сброс настроек Wi-Fi + +Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет. + +**Если сеть пропала или сменился пароль** — ничего делать не нужно: при +перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа +`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3). + +**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите +**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку +доступа. + +**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите +прибор по питанию. Не найдя сеть, он откроет портал настройки. + +Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при +сбросе Wi-Fi **не стираются**. + +--- + +## 8. Задержки срабатывания + +Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и +остановки: + +| Событие | Задержка | +|---|---| +| Фиксация нового состояния датчика | 3 секунды (не настраивается) | +| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с | +| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с | +| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** | +| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды | + +Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на +реле и на уведомление** — отдельно задержать только уведомление нельзя. + +Выдержка `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. Замените модуль либо потребуйте правку прошивки | +| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет | +| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` | +| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется | +| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание | + +Подробный лог работы выводится в USB-порт: скорость **115200 бод** +(Arduino IDE → Инструменты → Монитор порта). + +--- + +## 10. Безопасность + +Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может +открыть панель, сбросить Wi-Fi, изменить адрес уведомлений и **увеличить +выдержки аварии**, то есть замедлить реакцию сигнализации. Устанавливайте прибор +в закрытом технологическом сегменте сети, не в гостевом Wi-Fi. + +Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты. +Он не заменяет штатную автоматику защиты насосной станции. Кроме того, схема не +отказобезопасна: обесточенный контроллер сигнала аварии не подаёт (см. 2.3). diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md new file mode 100644 index 0000000..e586ef0 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -0,0 +1,530 @@ +# 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). Питание 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); } +``` + +### 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: `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","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. **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. Веб-интерфейс + +Сервер на порту `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. Сборка + +```bash +arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino +``` + +```bash +arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino +``` + +Монитор порта: 115200 бод. diff --git a/pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md b/pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md new file mode 100644 index 0000000..35efabd --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md @@ -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 резерв реагирует сразу; +* панель показывает состояние реле, совпадающее с фактическим щелчком. diff --git a/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino new file mode 100644 index 0000000..6f6ab32 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino @@ -0,0 +1,980 @@ +/* + ============================================================ + Контроллер насосов 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 +#include +#include +#include +#include +#include +#include +#include +#include // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики +#include +#include + +// --- 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 (8 строк по 8px при шрифте 1): + + ┌──────────────────────────┐ + │ PUMP CONTROLLER │ строка 0 — заголовок + │ ────────────────────── │ строка 1 — разделитель + │ НАСОС 1: РАБОТАЕТ │ строка 2 + │ НАСОС 2: СТОИТ │ строка 3 + │ РЕЗЕРВ : СТОИТ │ строка 4 + │ ────────────────────── │ строка 5 — разделитель + │ WiFi: 192.168.1.42 │ строка 6 + │ СТАТУС: НОРМА │ строка 7 + └──────────────────────────┘ + + При аварии строка 7 мигает "!! АВАРИЯ !!" +*/ + +// ============================================================ +// Реле аварий (модули 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; + + display.clearDisplay(); + display.setTextColor(SSD1306_WHITE); + + // --- Заголовок --- + display.setTextSize(1); + display.setCursor(0, 0); + display.print(F(" PUMP CONTROLLER v8")); + + // --- Разделитель --- + display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE); + + // --- Состояния насосов --- + display.setCursor(0, 14); + display.print(F("PUMP 1 : ")); + display.print(p1.stableState ? F("RUNNING") : F("STOPPED")); + + display.setCursor(0, 24); + display.print(F("PUMP 2 : ")); + display.print(p2.stableState ? F("RUNNING") : F("STOPPED")); + + display.setCursor(0, 34); + display.print(F("BACKUP : ")); + display.print(pE.stableState ? F("ACTIVE ") : F("STANDBY")); + + // --- Разделитель --- + display.drawFastHLine(0, 44, SCREEN_WIDTH, SSD1306_WHITE); + + // --- IP / статус WiFi --- + display.setCursor(0, 47); + if (WiFi.status() == WL_CONNECTED) { + display.print(WiFi.localIP().toString()); + } else { + display.print(F("WiFi: NO CONNECTION")); + } + + // --- Строка статуса --- + bool alarm = (!p1.stableState && !p2.stableState); + bool emergency = pE.stableState; + + display.setCursor(0, 56); + if (!systemReady) { + display.print(F("INIT...")); + } else if (alarm) { + // Мигание каждые 500 мс + if ((millis() / 500) % 2 == 0) { + display.fillRect(0, 54, SCREEN_WIDTH, 10, SSD1306_WHITE); + display.setTextColor(SSD1306_BLACK); + } + display.print(F("!! ALARM: NO PUMPS !!")); + display.setTextColor(SSD1306_WHITE); + } else if (emergency) { + display.print(F("WARN: BACKUP RUNNING")); + } else { + display.print(F("STATUS: OK")); + } + + display.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 status) { + 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"); + // settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование + // в JSON не требуется — сломать тело запроса ему нечем. + String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + + "\",\"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; +} + +// ============================================================ +// HTML интерфейс (без изменений логики, только IP-адрес актуален) +// ============================================================ +String getHTML() { + bool isAlarm = (!p1.stableState && !p2.stableState); + + String html = F(""); + html += ""; + html += F("" + "Мониторинг Насосов" + "
"); + + html += F("
" + "
SYS · PUMP_CTRL
" + "
Мониторинг
Насосов
" + "
LIVE · AUTO
" + "
--:--:--
" + "
"); + + html += "
"; + + // Насос 1 + html += "
"; + html += F("
Насос
" + "" + "" + "" + "" + "" + "" + "" + ""); + html += "
Основной 1
" + String(p1.stableState ? "Работает" : "Стоит") + "
"; + + // Насос 2 + html += "
"; + html += F("
Насос
" + "" + "" + "" + "" + "" + "" + "" + ""); + html += "
Основной 2
" + String(p2.stableState ? "Работает" : "Стоит") + "
"; + + // Резерв + html += "
"; + html += F("
Резерв
" + "" + "" + "" + "" + ""); + html += "
Резерв
" + String(pE.stableState ? "Активен" : "Ожидание") + "
"; + + html += "
"; + + if (isAlarm) { + html += "
CRITICAL · 0xFF
Нет подачи!
"; + } else { + html += "
SYS_OK · 0x00
Система: норма
"; + } + + html += "
Управление
"; + // --- Состояние реле --- + html += "
"; + html += "
"; + html += F("
Реле «Авария»
"); + html += "
"; + html += "
" + + String(relayMainOn ? "Включено" : "Выключено") + "
"; + + html += "
"; + html += F("
Реле «Резерв»
"); + html += "
"; + html += "
" + + String(relayEmergOn ? "Включено" : "Выключено") + "
"; + html += "
"; + + // --- Настройки --- + html += F("
Настройки
"); + if (!settingsFsReady) { + html += F("
" + "Файловая система недоступна — настройки не переживут перезагрузку!
"); + } + html += "
Webhook: " + + String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "
"; + html += F("
"); + html += F("
Webhook URL
" + "
"); + html += "
Имя устройства (A-Z a-z 0-9 _ -)
" + "
"; + html += "
Выдержка аварии, с
" + "
"; + html += "
Выдержка резерва, с
" + "
"; + html += F("
" + "
"); + + if (pE.stableState) { + html += "
WARN · BACKUP
Работает резерв!
"; + } + + html += F("
"); + + return html; +} + +// ============================================================ +// SETUP +// ============================================================ +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(200, "text/html", getHTML()); + }); + 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 += "}"; + 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); +}