From 75430a37329778265b3c81c2ff08ca79e1bdf623 Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 13:39:47 +0700 Subject: [PATCH 01/12] =?UTF-8?q?Initial=20commit:=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=B2=20v8.2=20+=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошивка Wemos D1 Mini для мониторинга трёх насосов: OLED, веб-панель, webhook-уведомления, сброс Wi-Fi кнопкой. Адрес webhook задаётся через веб-панель и хранится в EEPROM; в исходнике не хранится, так как содержит секретный токен. Добавлены README.md (техдокументация) и MANUAL.md (руководство). Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/.gitignore | 9 + pump_controller_8_2_OLED_DONE/MANUAL.md | 211 ++++++ pump_controller_8_2_OLED_DONE/README.md | 250 +++++++ .../pump_controller_8_2_OLED_DONE.ino | 635 ++++++++++++++++++ 4 files changed, 1105 insertions(+) create mode 100644 pump_controller_8_2_OLED_DONE/.gitignore create mode 100644 pump_controller_8_2_OLED_DONE/MANUAL.md create mode 100644 pump_controller_8_2_OLED_DONE/README.md create mode 100644 pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino 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..fdb43c1 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -0,0 +1,211 @@ +# Контроллер насосов 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 Выходы аварии + +| Клемма | Когда `HIGH` (3.3 В) | +|---|---| +| `D8` | Авария: оба основных насоса стоят | +| `D0` | Работает резервный насос | + +Выходы логические, 3.3 В, ток до ~12 мА. **Реле, сирену или лампу напрямую +подключать нельзя** — только через транзисторный ключ или релейный модуль +с оптронной развязкой. + +### 2.4 Кнопка сброса Wi-Fi + +Обычная кнопка без фиксации между `D3` и `GND`. + +> Кнопку нельзя держать нажатой в момент включения питания — плата уйдёт в +> служебный режим и не запустится. + +### 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**; +* поле **Webhook URL** для смены адреса уведомлений. + +Страница обновляется автоматически при изменении состояния насосов. + +### Адрес уведомлений + +**На новом приборе адрес не задан** — панель покажет +`не задан - уведомления отключены`, и сообщения на сервер отправляться не будут. +Введите адрес в поле под заголовком `Webhook URL` и нажмите **Сохранить**. + +Адрес сохраняется в память прибора и переживает отключение питания и сброс +Wi-Fi. Максимальная длина — 220 символов. + +--- + +## 6. Уведомления на сервер + +При изменении ситуации прибор отправляет сообщение на настроенный адрес: + +| Событие | Что означает | +|---|---| +| `MAIN PUMPS` / `ALARM` | Оба основных насоса стоят | +| `MAIN PUMPS` / `OK` | Работа восстановлена | +| `EMERGENCY PUMP` / `STARTED` | Запустился резервный насос | +| `EMERGENCY PUMP` / `STOPPED` | Резервный насос остановился | + +Уведомления отправляются один раз на каждое изменение, а не повторяются. +Если в момент события связи не было — уведомление **не будет** отправлено +позже, оно теряется. + +--- + +## 7. Сброс настроек Wi-Fi + +Нужен при смене роутера или пароля сети. + +**Способ 1 — кнопкой:** +нажмите и держите кнопку на `D3`. На экране появится полоса и обратный отсчёт +от 3 секунд. Дождитесь конца — прибор перезагрузится и снова поднимет точку +доступа `Pump_Control_Set`. Отпустите кнопку раньше — ничего не произойдёт. + +**Способ 2 — через браузер:** +на панели мониторинга нажмите **Сбросить Wi-Fi**. + +Webhook URL при сбросе Wi-Fi **не стирается**. + +--- + +## 8. Задержки срабатывания + +Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и +остановки: + +| Событие | Задержка | +|---|---| +| Фиксация нового состояния датчика | 3 секунды | +| Авария основных насосов (после фиксации) | ещё 5 секунд | +| **Итого до сигнала аварии** | **~8 секунд** | +| Реакция на резервный насос | ~3 секунды | + +--- + +## 9. Диагностика + +| Симптом | Причина / что делать | +|---|---| +| Экран не горит | Проверьте питание и провода SDA/SCL. Если адрес дисплея `0x3D`, требуется правка прошивки (константа `OLED_ADDR`) | +| Экран горит, но насосы всегда `STOPPED` | Контакты датчиков не замыкаются на `GND`; проверьте общий провод | +| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя | +| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) | +| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора | +| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод), строка `[HTTP] Код:` | +| Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой | + +Подробный лог работы выводится в USB-порт: скорость **115200 бод** +(Arduino IDE → Инструменты → Монитор порта). + +--- + +## 10. Безопасность + +Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может +открыть панель, сбросить Wi-Fi и изменить адрес уведомлений. Устанавливайте +прибор в закрытом технологическом сегменте сети, не в гостевом Wi-Fi. + +Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты. +Он не заменяет штатную автоматику защиты насосной станции. 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..19eb4cb --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -0,0 +1,250 @@ +# 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` | Руководство пользователя / монтажника | + +--- + +## 2. Аппаратная часть + +### 2.1 Плата + +Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или 3.3 В на пин `3V3`. + +### 2.2 Распиновка + +| Пин | Имя в коде | Режим | Логика | +|---|---|---|---| +| `D5` | `IN_PUMP_1` | `INPUT_PULLUP` | `LOW` = насос 1 работает | +| `D6` | `IN_PUMP_2` | `INPUT_PULLUP` | `LOW` = насос 2 работает | +| `D7` | `IN_PUMP_EMERGENCY` | `INPUT_PULLUP` | `LOW` = резервный насос работает | +| `D3` | `BTN_WIFI_RESET` | `INPUT_PULLUP` | `LOW` (замыкание на GND) = кнопка нажата | +| `D8` | `OUT_ALARM_MAIN` | `OUTPUT` | `HIGH` = авария основных насосов | +| `D0` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `HIGH` = работает резерв | +| `D1` | I2C SDA | — | OLED | +| `D2` | I2C SCL | — | OLED | + +Замечания по особенностям ESP8266: + +* `D3` (GPIO0) и `D8` (GPIO15) участвуют в выборе режима загрузки. `D8` должен + быть подтянут к GND при старте — не вешайте на него нагрузку, тянущую пин вверх. + Кнопку на `D3` при старте держать нажатой нельзя (плата уйдёт в режим прошивки). +* `D0` (GPIO16) не имеет внутреннего pull-up (только pull-down) и не поддерживает + прерывания — используется только как выход. + +### 2.3 Дисплей + +SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (константа `OLED_ADDR`). +Если экран не найден — в лог уходит сообщение, прошивка продолжает работать +без дисплея (`oledOK = false`). + +### 2.4 Подключение датчиков + +Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ). +Замкнут на GND → насос считается работающим. Внешние резисторы не нужны, +используется внутренняя подтяжка. + +--- + +## 3. Зависимости + +Устанавливаются через Arduino Library Manager: + +* Adafruit SSD1306 +* Adafruit GFX Library +* WiFiManager (tzapu) + +Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`, +`WiFiClientSecure`, `EEPROM`, `Wire`. + +Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600, Flash size 4MB. + +--- + +## 4. Логика работы + +### 4.1 Антидребезг + +`updatePumpState()` для каждого входа: + +``` +если чтение изменилось -> сбросить таймер lastDebounceTime +если чтение стабильно > 3000 мс -> зафиксировать stableState +``` + +`debounceDelay = 3000 мс` — длинный намеренно: фильтрует пусковые дребезги +пускателя и кратковременные просадки. + +### 4.2 Готовность системы + +`systemReady` становится `true` через `debounceDelay + 500 мс` после старта. +До этого момента аварии не формируются и webhook не отправляется — иначе +при включении питания система рапортовала бы ложную аварию. + +### 4.3 Основная авария + +Условие: **оба** основных насоса стоят (`!p1 && !p2`). + +``` +bothStopped -> запуск таймера mainAlarmStartTime + выдержка alarmDelay = 5000 мс + -> OUT_ALARM_MAIN = HIGH + -> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно) + +любой насос запустился + -> OUT_ALARM_MAIN = LOW + -> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно) +``` + +Суммарная задержка от факта остановки до аварии: `3 с` (дребезг) + `5 с` +(выдержка) ≈ **8 секунд**. + +### 4.4 Резервный насос + +Реакция без дополнительной выдержки, сразу после стабилизации входа: + +``` +pE = работает -> OUT_ALARM_EMERGENCY = HIGH, webhook EMERGENCY PUMP / STARTED +pE = стоит -> OUT_ALARM_EMERGENCY = LOW, webhook EMERGENCY PUMP / STOPPED +``` + +Флаги `mainAlarmSent` / `emergencyActiveSent` гарантируют отправку ровно +одного webhook на каждый переход состояния. + +--- + +## 5. Webhook + +`POST` на `webhook_url`, `Content-Type: application/json`: + +```json +{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"} +``` + +| `event` | `status` | +|---|---| +| `MAIN PUMPS` | `ALARM` / `OK` | +| `EMERGENCY PUMP` | `STARTED` / `STOPPED` | + +TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` — +сертификат сервера **не проверяется**. Достаточно для отправки в доверенную +локальную/корпоративную инфраструктуру, но не защищает от MITM. + +Отправка синхронная и блокирующая: на время POST (до нескольких секунд при +недоступном сервере) основной цикл приостанавливается. Дисплей в этот момент +не обновляется. + +--- + +## 6. Хранение настроек (EEPROM) + +Эмулируемая EEPROM, 256 байт. + +| Адрес | Размер | Содержимое | +|---|---|---| +| `0` | 2 | Magic `0xA55A` — признак инициализации | +| `2` | 220 | Webhook URL, `\0`-терминированный | + +При первом старте (magic не совпал) записывается значение константы +`webhook_url_default` — она намеренно пустая, чтобы секретный токен не попадал +в исходник и в git. Пока URL не задан через `/set_webhook`, `sendPostWebhook()` +сразу выходит и пишет в лог `[HTTP] Webhook URL не задан`. + +Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM. + +--- + +## 7. Веб-интерфейс + +Сервер на порту `80`. + +| Маршрут | Метод | Описание | +|---|---|---| +| `/` | `GET` | HTML-панель мониторинга | +| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0}` | +| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться | +| `/set_webhook` | `POST` | Параметр `url` (≤220 симв.), сохранить в EEPROM | + +Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при +изменении состояния. Шрифты подгружаются с 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` | + +--- + +## 9. Кнопка сброса Wi-Fi + +Пин `D3`, удержание `BTN_HOLD_MS = 3000 мс`. Во время удержания на дисплее +рисуется прогресс-бар с обратным отсчётом. По достижении порога — +`wm.resetSettings()` и `ESP.restart()`. Отпускание до порога отменяет операцию. + +--- + +## 10. Известные ограничения + +* `setInsecure()` — TLS без проверки сертификата. +* Веб-интерфейс и `/reset_wifi` не защищены паролем. +* Webhook отправляется синхронно и блокирует цикл; при недоступном сервере + реакция контроллера замедляется на время таймаута. +* События, произошедшие без Wi-Fi, не буферизуются — webhook просто теряется + (в лог пишется `[HTTP] Нет Wi-Fi`). +* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией + не считается и никак не сигнализируется. +* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем, + кто может её открыть. Если 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/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..316cfa6 --- /dev/null +++ b/pump_controller_8_2_OLED_DONE/pump_controller_8_2_OLED_DONE.ino @@ -0,0 +1,635 @@ +/* + ============================================================ + Контроллер насосов v8 — Wemos D1 Mini + ============================================================ + Распиновка: + D5 — IN_PUMP_1 (датчик, INPUT_PULLUP, LOW = работает) + D6 — IN_PUMP_2 (датчик, INPUT_PULLUP, LOW = работает) + D7 — IN_PUMP_EMERGENCY (датчик, INPUT_PULLUP, LOW = работает) + D8 — OUT_ALARM_MAIN (выход, HIGH = авария) + D0 — OUT_ALARM_EMERGENCY (выход, HIGH = авария; активен HIGH несмотря на pull-up к VCC) + BTN_WIFI_RESET = D3; // Кнопка сброса WiFi (INPUT_PULLUP, GND при нажатии) + D1 — OLED SDA (I2C) + D2 — OLED SCL (I2C) + + Библиотеки (установить через Library Manager): + - Adafruit SSD1306 + - Adafruit GFX Library + - WiFiManager (tzapu) + - ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient (часть ESP8266 core) + ============================================================ +*/ + +#include +#include +#include +#include +#include +#include +#include +#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 задаётся через веб-панель (/set_webhook) и хранится в EEPROM. +// В исходнике намеренно пусто: URL содержит секретный токен. +const char* webhook_url_default = ""; + +const int EEPROM_SIZE = 256; +const int EEPROM_MAGIC_ADDR = 0; +const uint16_t EEPROM_MAGIC = 0xA55A; +const int EEPROM_URL_ADDR = 2; +const int WEBHOOK_MAX_LEN = 220; + +char webhook_url[WEBHOOK_MAX_LEN + 1]; + +// --- ПИНЫ --- +const int IN_PUMP_1 = D5; +const int IN_PUMP_2 = D6; +const int IN_PUMP_EMERGENCY = D7; +const int OUT_ALARM_MAIN = D8; // HIGH = авария +const int OUT_ALARM_EMERGENCY = D0; // HIGH = авария (D0 pull-up к VCC — при старте не тянет HIGH) +const int BTN_WIFI_RESET = D3; // Кнопка сброса WiFi (INPUT_PULLUP, GND при нажатии) + +// --- КНОПКА СБРОСА WiFi --- +const unsigned long BTN_HOLD_MS = 3000; // Удержание 3 сек для сброса +unsigned long btnPressStart = 0; +bool btnHolding = 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; +const unsigned long alarmDelay = 5000; + +bool mainAlarmSent = false; +bool emergencyActiveSent = false; +bool systemReady = false; +unsigned long mainAlarmStartTime = 0; +bool mainAlarmPending = false; + +// Для обновления дисплея не чаще раза в секунду +unsigned long lastDisplayUpdate = 0; +const unsigned long DISPLAY_INTERVAL = 1000; + +// ============================================================ +// EEPROM +// ============================================================ +void eepromWriteUrl(const char* url) { + EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC); + for (int i = 0; i < WEBHOOK_MAX_LEN; i++) { + EEPROM.write(EEPROM_URL_ADDR + i, i < (int)strlen(url) ? url[i] : 0); + } + EEPROM.commit(); + Serial.println("[EEPROM] URL сохранён: " + String(url)); +} + +void eepromReadUrl() { + uint16_t magic; + EEPROM.get(EEPROM_MAGIC_ADDR, magic); + if (magic == EEPROM_MAGIC) { + for (int i = 0; i < WEBHOOK_MAX_LEN; i++) { + webhook_url[i] = EEPROM.read(EEPROM_URL_ADDR + i); + } + webhook_url[WEBHOOK_MAX_LEN] = '\0'; + Serial.println("[EEPROM] URL загружен: " + String(webhook_url)); + } else { + strncpy(webhook_url, webhook_url_default, WEBHOOK_MAX_LEN); + webhook_url[WEBHOOK_MAX_LEN] = '\0'; + eepromWriteUrl(webhook_url); + Serial.println("[EEPROM] Первый старт. URL по умолчанию сохранён."); + } +} + +// ============================================================ +// OLED — отображение состояния +// ============================================================ +/* + Макет экрана 128x64 (8 строк по 8px при шрифте 1): + + ┌──────────────────────────┐ + │ PUMP CONTROLLER │ строка 0 — заголовок + │ ────────────────────── │ строка 1 — разделитель + │ НАСОС 1: РАБОТАЕТ │ строка 2 + │ НАСОС 2: СТОИТ │ строка 3 + │ РЕЗЕРВ : СТОИТ │ строка 4 + │ ────────────────────── │ строка 5 — разделитель + │ WiFi: 192.168.1.42 │ строка 6 + │ СТАТУС: НОРМА │ строка 7 + └──────────────────────────┘ + + При аварии строка 7 мигает "!! АВАРИЯ !!" +*/ + +// ============================================================ +// Кнопка сброса WiFi (удержание BTN_HOLD_MS мс) +// ============================================================ +void handleResetButton() { + bool pressed = (digitalRead(BTN_WIFI_RESET) == LOW); + + if (pressed) { + if (!btnHolding) { + btnHolding = true; + btnPressStart = millis(); + } else { + unsigned long held = millis() - btnPressStart; + + // Показываем прогресс-бар на дисплее + if (oledOK && held < BTN_HOLD_MS) { + display.clearDisplay(); + display.setTextSize(1); + display.setTextColor(SSD1306_WHITE); + display.setCursor(10, 10); + display.print(F("Hold to reset WiFi")); + // Прогресс-бар + int barW = (int)((held * 108UL) / BTN_HOLD_MS); + display.drawRect(10, 28, 108, 12, SSD1306_WHITE); + display.fillRect(10, 28, barW, 12, SSD1306_WHITE); + // Счётчик секунд + display.setCursor(10, 46); + display.print((BTN_HOLD_MS - held) / 1000 + 1); + display.print(F(" sec...")); + display.display(); + } + + // Порог удержания достигнут — сброс + if (held >= BTN_HOLD_MS) { + Serial.println(F("[BTN] WiFi reset by button!")); + if (oledOK) { + display.clearDisplay(); + display.setCursor(20, 20); + display.print(F("WiFi RESET!")); + display.setCursor(10, 36); + display.print(F("Rebooting...")); + display.display(); + } + delay(1500); + WiFiManager wm; + wm.resetSettings(); + ESP.restart(); + } + } + } else { + // Кнопка отпущена до порога — сброс состояния + if (btnHolding) { + btnHolding = false; + lastDisplayUpdate = 0; // принудительное обновление дисплея + } + } +} + +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 +// ============================================================ +void sendPostWebhook(String eventName, String status) { + if (webhook_url[0] == '\0') { + Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена")); + return; + } + if (WiFi.status() == WL_CONNECTED) { + WiFiClientSecure client; + client.setInsecure(); + HTTPClient http; + Serial.print("[HTTP] POST: "); + Serial.println(webhook_url); + if (http.begin(client, webhook_url)) { + http.addHeader("Content-Type", "application/json"); + String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}"; + int code = http.POST(body); + if (code > 0) Serial.printf("[HTTP] Код: %d\n", code); + else Serial.printf("[HTTP] Ошибка: %s\n", http.errorToString(code).c_str()); + http.end(); + } + } else { + Serial.println("[HTTP] Нет Wi-Fi"); + } +} + +// ============================================================ +// Дребезг + обновление стабильного состояния +// ============================================================ +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); + bool isEmergency = pE.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 += "
Webhook URL
" + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + + 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) и провода.")); + } + + EEPROM.begin(EEPROM_SIZE); + eepromReadUrl(); + + pinMode(p1.pin, INPUT_PULLUP); + pinMode(p2.pin, INPUT_PULLUP); + pinMode(pE.pin, INPUT_PULLUP); + pinMode(BTN_WIFI_RESET, INPUT_PULLUP); + pinMode(OUT_ALARM_MAIN, OUTPUT); + pinMode(OUT_ALARM_EMERGENCY, OUTPUT); + digitalWrite(OUT_ALARM_MAIN, LOW); + digitalWrite(OUT_ALARM_EMERGENCY, LOW); + + 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 += "}"; + 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_webhook", HTTP_POST, []() { + if (server.hasArg("url") && server.arg("url").length() > 0) { + String newUrl = server.arg("url"); newUrl.trim(); + if (newUrl.length() <= WEBHOOK_MAX_LEN) { + newUrl.toCharArray(webhook_url, WEBHOOK_MAX_LEN + 1); + eepromWriteUrl(webhook_url); + server.sendHeader("Location", "/"); server.send(303); + } + } else { + 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(); + handleResetButton(); + + 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; + } + + // --- Основная авария --- + bool bothStopped = (!p1.stableState && !p2.stableState); + if (bothStopped) { + if (!mainAlarmPending) { + mainAlarmPending = true; + mainAlarmStartTime = millis(); + } else if (millis() - mainAlarmStartTime >= alarmDelay) { + digitalWrite(OUT_ALARM_MAIN, HIGH); + if (!mainAlarmSent) { + Serial.println(F("[ALERT] Авария основных насосов")); + sendPostWebhook("MAIN PUMPS", "ALARM"); + mainAlarmSent = true; + } + } + } else { + digitalWrite(OUT_ALARM_MAIN, LOW); + mainAlarmPending = false; + if (mainAlarmSent) { + Serial.println(F("[INFO] Восстановление: основные насосы OK")); + sendPostWebhook("MAIN PUMPS", "OK"); + mainAlarmSent = false; + } + } + + // --- Резервный насос --- + if (pE.stableState && !emergencyActiveSent) { + digitalWrite(OUT_ALARM_EMERGENCY, HIGH); + Serial.println(F("[WARN] Запущен экстренный насос")); + sendPostWebhook("EMERGENCY PUMP", "STARTED"); + emergencyActiveSent = true; + } else if (!pE.stableState && emergencyActiveSent) { + digitalWrite(OUT_ALARM_EMERGENCY, LOW); + Serial.println(F("[INFO] Резерв остановлен")); + sendPostWebhook("EMERGENCY PUMP", "STOPPED"); + emergencyActiveSent = false; + } + + // --- Обновление дисплея раз в секунду --- + if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) { + updateDisplay(); + lastDisplayUpdate = millis(); + } + + delay(10); +} From 355a9912abe892550c4c8e0cc1a7ed63ed21cdcc Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 14:40:42 +0700 Subject: [PATCH 02/12] =?UTF-8?q?=D0=9E=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D1=83=20loop()=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20webhook=20=D0=B1?= =?UTF-8?q?=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=D0=BE=D0=BC=203=20=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BearSSL делает TLS-хендшейк синхронно, поэтому вместо асинхронности блокировка ограничивается по времени. Главный источник залипания — не таймауты HTTPClient, а DNS: WiFiClientSecureCtx::connect(name, port) резолвит имя через WiFi.hostByName() без таймаута и подвешивает loop() на ~10 с. Теперь имя разрешается заранее с лимитом 800 мс, результат попадает в кэш lwIP, и внутренний резолв возвращается мгновенно. Соединение по-прежнему идёт по имени, поэтому SNI и заголовок Host сохраняются. Остаток бюджета уходит в http.setTimeout(): он ограничивает и connect+TLS (через _client->setTimeout() до connect), и чтение ответа (собственный цикл HTTPClient по _tcpTimeout). Худший случай: ~3.5 с вместо прежних 10-15 с. Выходы аварии не затронуты — digitalWrite() и раньше выполнялся до отправки. Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра 3.1.2 — в скетче замечаний нет; разбор URL прогнан на 15 граничных случаях. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 8 +- pump_controller_8_2_OLED_DONE/README.md | 52 ++++++-- .../pump_controller_8_2_OLED_DONE.ino | 118 +++++++++++++++--- 3 files changed, 154 insertions(+), 24 deletions(-) diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index fdb43c1..d037af0 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -152,6 +152,11 @@ Wi-Fi. Максимальная длина — 220 символов. Если в момент события связи не было — уведомление **не будет** отправлено позже, оно теряется. +На отправку отводится не больше **3 секунд**. Если сервер недоступен или +отвечает медленно, прибор прекращает попытку и продолжает работу: экран и +кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не +дожидаясь сервера. + --- ## 7. Сброс настроек Wi-Fi @@ -193,7 +198,8 @@ Webhook URL при сбросе Wi-Fi **не стирается**. | Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя | | `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) | | Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора | -| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод), строка `[HTTP] Код:` | +| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя | +| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Выходы аварии при этом срабатывают сразу, без ожидания сервера | | Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой | Подробный лог работы выводится в USB-порт: скорость **115200 бод** diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index 19eb4cb..c501e44 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -143,9 +143,44 @@ TLS-соединение поднимается через `WiFiClientSecure` с сертификат сервера **не проверяется**. Достаточно для отправки в доверенную локальную/корпоративную инфраструктуру, но не защищает от MITM. -Отправка синхронная и блокирующая: на время POST (до нескольких секунд при -недоступном сервере) основной цикл приостанавливается. Дисплей в этот момент -не обновляется. +### 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 мс)`. --- @@ -225,10 +260,13 @@ STATUS: OK * `setInsecure()` — TLS без проверки сертификата. * Веб-интерфейс и `/reset_wifi` не защищены паролем. -* Webhook отправляется синхронно и блокирует цикл; при недоступном сервере - реакция контроллера замедляется на время таймаута. -* События, произошедшие без Wi-Fi, не буферизуются — webhook просто теряется - (в лог пишется `[HTTP] Нет Wi-Fi`). +* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета + (см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется + **до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только + обновление OLED, опрос кнопки и веб-панель. +* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются + и не повторяются — webhook теряется, флаги `mainAlarmSent` / + `emergencyActiveSent` выставляются независимо от результата POST. * Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией не считается и никак не сигнализируется. * Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем, 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 index 316cfa6..3a3502e 100644 --- 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 @@ -84,6 +84,16 @@ Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0}; const unsigned long debounceDelay = 3000; const unsigned long alarmDelay = 5000; +// --- БЮДЖЕТ ОТПРАВКИ 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; @@ -265,28 +275,104 @@ void updateDisplay() { // ============================================================ // 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 (webhook_url[0] == '\0') { Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена")); return; } - if (WiFi.status() == WL_CONNECTED) { - WiFiClientSecure client; - client.setInsecure(); - HTTPClient http; - Serial.print("[HTTP] POST: "); - Serial.println(webhook_url); - if (http.begin(client, webhook_url)) { - http.addHeader("Content-Type", "application/json"); - String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}"; - int code = http.POST(body); - if (code > 0) Serial.printf("[HTTP] Код: %d\n", code); - else Serial.printf("[HTTP] Ошибка: %s\n", http.errorToString(code).c_str()); - http.end(); - } - } else { - Serial.println("[HTTP] Нет Wi-Fi"); + if (WiFi.status() != WL_CONNECTED) { + Serial.println(F("[HTTP] Нет Wi-Fi")); + return; } + + const unsigned long started = millis(); + + String host; + if (!parseWebhookHost(webhook_url, host)) { + Serial.println("[HTTP] Не удалось разобрать URL: " + String(webhook_url)); + 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(webhook_url); + + if (!http.begin(client, webhook_url)) { + Serial.println(F("[HTTP] begin() не удался")); + return; + } + + http.addHeader("Content-Type", "application/json"); + String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}"; + 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(); } // ============================================================ From 4c760e943fae5e875713135db0b08420175d189f Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 14:59:57 +0700 Subject: [PATCH 03/12] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B2=D0=B0=20=D0=BF=D1=80=D0=B8=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8B=D1=85=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отсутствие ретраев webhook — сознательное решение: автономная работа без отправки событий это штатный режим. Пауза мониторинга до 2 минут при старте без сохранённой сети (блокирующий портал WiFiManager в setup()) — принята как есть, с указанием способа лечения, если понадобится. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index c501e44..360a7b2 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -267,6 +267,18 @@ STATUS: OK * События, произошедшие без 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 хранится в EEPROM в открытом виде и отдаётся веб-панелью всем, From cf61617a4651b7262191efd5b7816d25ae3da0dd Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 16:02:21 +0700 Subject: [PATCH 04/12] =?UTF-8?q?=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=B1,=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=80=D0=B5=D0=BB=D0=B5=20=D0=B2=20UI,=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D1=80=D0=B5=D0=BB=D0=B5=20active-LOW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Распиновка. Пина D0, который использовался под выход «Резерв», в доступном наборе нет. Кнопка сброса Wi-Fi удалена как избыточная: при пропавшей сети wm.autoConnect() сам поднимает портал. Освободившийся D3 закрыл дефицит. Выходы переехали на D3 и D4 и стали инверсными. Причина: для реле active-LOW безопасное состояние — пин в HIGH, а D3/D4 подтянуты к HIGH внешними резисторами платы и держат этот уровень всю загрузку. D8, наоборот, подтянут к LOW и щёлкал бы реле при каждом включении питания. В setup() digitalWrite() идёт до pinMode(), иначе защёлка выхода даёт короткий LOW. Инверсия живёт только в setRelay(); логическое состояние дублируется в relayMainOn/relayEmergOn, чтобы панель показывала смысл, а не уровень пина. Настройки. device, выдержка аварии и выдержка резерва вынесены в /set_config и EEPROM. Раскладка EEPROM версионирована: magic 0xA55B, старый 0xA55A распознаётся и переносится, поэтому прошитые приборы не теряют webhook. Значения по умолчанию воспроизводят прежнее поведение (5 с и 0 с). Для резерва добавлена выдержка, которой раньше не было, симметрично основной аварии. Обе выдержки гасят реле и вебхук одновременно. device_name фильтруется до [A-Za-z0-9_-] вместо экранирования: такой набор безопасен и в JSON, и в HTML. Пустое или неверное поле формы означает «не менять», поэтому частичное заполнение не сбрасывает остальное. Состояние реле выведено в /status и в панель. Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра 3.1.2 — предупреждений в скетче нет (попутно убран неиспользуемый isEmergency); валидация настроек прогнана на 22 граничных случаях; раскладка EEPROM проверена на перекрытия. На железе не проверялось. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 137 ++++-- pump_controller_8_2_OLED_DONE/README.md | 206 +++++++-- .../2026-08-07-config-relays-pinout-design.md | 185 ++++++++ .../pump_controller_8_2_OLED_DONE.ino | 414 ++++++++++++------ 4 files changed, 734 insertions(+), 208 deletions(-) create mode 100644 pump_controller_8_2_OLED_DONE/docs/specs/2026-08-07-config-relays-pinout-design.md diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index d037af0..ff27499 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -42,23 +42,40 @@ **Контакт замкнут = насос работает.** Полярность не важна, внешние резисторы и питание на контакты подавать не нужно. -### 2.3 Выходы аварии +### 2.3 Выходы на реле -| Клемма | Когда `HIGH` (3.3 В) | -|---|---| -| `D8` | Авария: оба основных насоса стоят | -| `D0` | Работает резервный насос | +Прибор рассчитан на **релейные модули с активным LOW** — те, у которых реле +включается замыканием входа на GND. Это самый распространённый тип модулей для +Arduino. -Выходы логические, 3.3 В, ток до ~12 мА. **Реле, сирену или лампу напрямую -подключать нельзя** — только через транзисторный ключ или релейный модуль -с оптронной развязкой. +| Клемма платы | Куда | Реле включается когда | +|---|---|---| +| `D3` | вход `IN` модуля «Авария» | оба основных насоса стоят | +| `D4` | вход `IN` модуля «Резерв» | работает резервный насос | -### 2.4 Кнопка сброса Wi-Fi +Питание модулей — от `5V` и `GND` платы (или от отдельного источника с общим +GND). **Сирену, лампу или пускатель напрямую к `D3`/`D4` подключать нельзя** — +только через релейный модуль или транзисторный ключ. -Обычная кнопка без фиксации между `D3` и `GND`. +> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с +> активным HIGH, реле будет включено в норме и отпускать при аварии — логика +> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают +> HIGH во время загрузки платы, поэтому реле молчат при включении питания. -> Кнопку нельзя держать нажатой в момент включения питания — плата уйдёт в -> служебный режим и не запустится. +Оговорки, которые лучше знать заранее: + +* Реле «Авария» **щёлкает при каждой заливке прошивки** — схема автосброса USB + дёргает `D3`. Это нормально. +* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для + проверки монтажа. +* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет. + Если это критично — снимайте нагрузку с **нормально замкнутого (NC)** контакта + реле, тогда пропажа питания читается системой как авария. + +### 2.4 Свободные клеммы + +`D8`, `D0` и `A0` прошивкой не используются. Учтите, что `A0` — только +аналоговый вход и цифровым выходом стать не может. ### 2.5 Экран @@ -121,19 +138,47 @@ STATUS: OK * три карточки насосов с индикаторами; * панель состояния системы (`SYS_OK` или `CRITICAL`); +* **состояние обоих реле** — «Включено» / «Выключено»; * кнопка **Сбросить Wi-Fi**; -* поле **Webhook URL** для смены адреса уведомлений. +* блок **Настройки** — адрес уведомлений, имя устройства и две выдержки. -Страница обновляется автоматически при изменении состояния насосов. +Страница обновляется автоматически при изменении состояния насосов или реле. -### Адрес уведомлений +### 5.1 Состояние реле -**На новом приборе адрес не задан** — панель покажет -`не задан - уведомления отключены`, и сообщения на сервер отправляться не будут. -Введите адрес в поле под заголовком `Webhook URL` и нажмите **Сохранить**. +Два индикатора показывают, что прибор реально подал на выход: -Адрес сохраняется в память прибора и переживает отключение питания и сброс -Wi-Fi. Максимальная длина — 220 символов. +| Индикатор | Значение | +|---|---| +| Реле «Авария» — Включено | Выход `D3` активен, сигнал аварии выдан | +| Реле «Резерв» — Включено | Выход `D4` активен, резерв работает | + +Полезно при пусконаладке: если реле показано «Включено», а нагрузка не +сработала — проблема в монтаже или в типе релейного модуля, а не в прошивке. + +### 5.2 Настройки + +Все настройки в одной форме. **Пустое поле означает «не менять»** — можно +поправить одну выдержку, не трогая остальное. Неверное значение так же +игнорируется, поэтому опечатка ничего не испортит. + +| Поле | Что задаёт | Допустимо | По умолчанию | +|---|---|---|---| +| **Webhook URL** | адрес сервера уведомлений | до 220 символов | не задан | +| **Имя устройства** | поле `device` в уведомлении — по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` | +| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` | +| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) | + +Настройки сохраняются в памяти прибора, переживают отключение питания и сброс +Wi-Fi. + +> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут +> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки. +> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`. + +**На новом приборе адрес уведомлений не задан** — панель покажет +`не задан - уведомления отключены`, сообщения отправляться не будут, всё +остальное работает. --- @@ -161,17 +206,21 @@ Wi-Fi. Максимальная длина — 220 символов. ## 7. Сброс настроек Wi-Fi -Нужен при смене роутера или пароля сети. +Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет. -**Способ 1 — кнопкой:** -нажмите и держите кнопку на `D3`. На экране появится полоса и обратный отсчёт -от 3 секунд. Дождитесь конца — прибор перезагрузится и снова поднимет точку -доступа `Pump_Control_Set`. Отпустите кнопку раньше — ничего не произойдёт. +**Если сеть пропала или сменился пароль** — ничего делать не нужно: при +перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа +`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3). -**Способ 2 — через браузер:** -на панели мониторинга нажмите **Сбросить Wi-Fi**. +**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите +**Сбросить Wi-Fi** на панели мониторинга. Прибор перезагрузится и поднимет точку +доступа. -Webhook URL при сбросе Wi-Fi **не стирается**. +**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите +прибор по питанию. Не найдя сеть, он откроет портал настройки. + +Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при +сбросе Wi-Fi **не стираются**. --- @@ -182,10 +231,17 @@ Webhook URL при сбросе Wi-Fi **не стирается**. | Событие | Задержка | |---|---| -| Фиксация нового состояния датчика | 3 секунды | -| Авария основных насосов (после фиксации) | ещё 5 секунд | -| **Итого до сигнала аварии** | **~8 секунд** | -| Реакция на резервный насос | ~3 секунды | +| Фиксация нового состояния датчика | 3 секунды (не настраивается) | +| Авария основных насосов (после фиксации) | **Выдержка аварии**, по умолчанию 5 с | +| Пуск резерва (после фиксации) | **Выдержка резерва**, по умолчанию 0 с | +| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** | +| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды | + +Обе выдержки настраиваются в панели (раздел 5.2) и действуют **одновременно на +реле и на уведомление** — отдельно задержать только уведомление нельзя. + +Выдержка `0` означает «сразу после фиксации», то есть через те же 3 секунды +антидребезга. --- @@ -199,8 +255,13 @@ Webhook URL при сбросе Wi-Fi **не стирается**. | `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) | | Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора | | Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя | -| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Выходы аварии при этом срабатывают сразу, без ожидания сервера | -| Прибор не стартует после включения | Была нажата кнопка `D3` при подаче питания либо `D8` притянут к плюсу нагрузкой | +| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера | +| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён | +| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки | +| Реле щёлкает при заливке прошивки | Нормально: схема автосброса USB дёргает `D3`. На работу не влияет | +| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` | +| Выдержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется | +| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание | Подробный лог работы выводится в USB-порт: скорость **115200 бод** (Arduino IDE → Инструменты → Монитор порта). @@ -210,8 +271,10 @@ Webhook URL при сбросе Wi-Fi **не стирается**. ## 10. Безопасность Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может -открыть панель, сбросить Wi-Fi и изменить адрес уведомлений. Устанавливайте -прибор в закрытом технологическом сегменте сети, не в гостевом Wi-Fi. +открыть панель, сбросить 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 index 360a7b2..cc9ec82 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -15,6 +15,7 @@ | `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) | | `README.md` | Техническая документация (этот файл) | | `MANUAL.md` | Руководство пользователя / монтажника | +| `docs/specs/` | Согласованные проектные решения по крупным изменениям | --- @@ -26,32 +27,70 @@ Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или ### 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` | `BTN_WIFI_RESET` | `INPUT_PULLUP` | `LOW` (замыкание на GND) = кнопка нажата | -| `D8` | `OUT_ALARM_MAIN` | `OUTPUT` | `HIGH` = авария основных насосов | -| `D0` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `HIGH` = работает резерв | -| `D1` | I2C SDA | — | OLED | -| `D2` | I2C SCL | — | OLED | +| `D3` | `OUT_ALARM_MAIN` | `OUTPUT` | `LOW` = реле «Авария» включено | +| `D4` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `LOW` = реле «Резерв» включено | +| `D8`, `D0`, `A0` | — | — | не используются | -Замечания по особенностям ESP8266: +### 2.3 Почему выходы именно на D3 и D4 -* `D3` (GPIO0) и `D8` (GPIO15) участвуют в выборе режима загрузки. `D8` должен - быть подтянут к GND при старте — не вешайте на него нагрузку, тянущую пин вверх. - Кнопку на `D3` при старте держать нажатой нельзя (плата уйдёт в режим прошивки). -* `D0` (GPIO16) не имеет внутреннего pull-up (только pull-down) и не поддерживает - прерывания — используется только как выход. +Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан +сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не +выполнился, иначе реле щёлкнет ложной аварией при каждом включении питания. -### 2.3 Дисплей +| Пин | 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.4 Подключение датчиков +### 2.6 Подключение датчиков Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ). Замкнут на GND → насос считается работающим. Внешние резисторы не нужны, @@ -100,27 +139,40 @@ Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600, Flash size 4MB. ``` bothStopped -> запуск таймера mainAlarmStartTime - выдержка alarmDelay = 5000 мс - -> OUT_ALARM_MAIN = HIGH + выдержка alarmDelaySec (настраивается, по умолчанию 5 с) + -> реле «Авария» включено -> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно) любой насос запустился - -> OUT_ALARM_MAIN = LOW + -> реле «Авария» выключено -> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно) ``` -Суммарная задержка от факта остановки до аварии: `3 с` (дребезг) + `5 с` -(выдержка) ≈ **8 секунд**. +Суммарная задержка от факта остановки до аварии: `3 с` (дребезг, константа +`debounceDelay`) + `alarmDelaySec`. При значениях по умолчанию ≈ **8 секунд**. ### 4.4 Резервный насос -Реакция без дополнительной выдержки, сразу после стабилизации входа: +Логика симметрична основной аварии — та же схема «ожидание, затем реакция»: ``` -pE = работает -> OUT_ALARM_EMERGENCY = HIGH, webhook EMERGENCY PUMP / STARTED -pE = стоит -> OUT_ALARM_EMERGENCY = LOW, webhook EMERGENCY PUMP / STOPPED +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 на каждый переход состояния. @@ -139,6 +191,11 @@ pE = стоит -> OUT_ALARM_EMERGENCY = LOW, webhook EMERGENCY PUMP / STOPP | `MAIN PUMPS` | `ALARM` / `OK` | | `EMERGENCY PUMP` | `STARTED` / `STOPPED` | +Поле `device` берётся из настройки `device_name` (задаётся через веб-панель, +по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до +`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело +запроса ему нечем. + TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` — сертификат сервера **не проверяется**. Достаточно для отправки в доверенную локальную/корпоративную инфраструктуру, но не защищает от MITM. @@ -186,19 +243,41 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о ## 6. Хранение настроек (EEPROM) -Эмулируемая EEPROM, 256 байт. +Эмулируемая EEPROM, 512 байт. Занято 258. -| Адрес | Размер | Содержимое | -|---|---|---| -| `0` | 2 | Magic `0xA55A` — признак инициализации | -| `2` | 220 | Webhook URL, `\0`-терминированный | +| Адрес | Размер | Содержимое | По умолчанию | +|---|---|---|---| +| `0` | 2 | Magic `0xA55B` — версия раскладки 2 | — | +| `2` | 220 | `webhook_url`, `\0`-терминированный | пусто | +| `222` | 31 | `device_name` | `Wemos_D1_Pump` | +| `254` | 2 | `alarm_delay_sec` (`uint16`) | `5` | +| `256` | 2 | `emerg_delay_sec` (`uint16`) | `0` | -При первом старте (magic не совпал) записывается значение константы -`webhook_url_default` — она намеренно пустая, чтобы секретный токен не попадал -в исходник и в git. Пока URL не задан через `/set_webhook`, `sendPostWebhook()` -сразу выходит и пишет в лог `[HTTP] Webhook URL не задан`. +Значения по умолчанию воспроизводят поведение прошивки до появления настроек. -Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM. +### 6.1 Версионирование и миграция + +Magic одновременно служит номером версии раскладки: + +| Прочитанный magic | Действие | +|---|---| +| `0xA55B` | читаются все поля | +| `0xA55A` | **миграция v1 → v2**: `webhook_url` сохраняется, новые поля получают значения по умолчанию, magic перезаписывается | +| иное | первый старт, все поля по умолчанию | + +Миграция нужна, чтобы уже прошитые приборы не потеряли настроенный webhook при +обновлении прошивки. + +При чтении v2 значения дополнительно проверяются: пустое `device_name` и +выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию — иначе мусор +во флеше дал бы заведомо неверную выдержку. + +Пустой `webhook_url` — легальное состояние. Пока URL не задан, +`sendPostWebhook()` сразу выходит и пишет в лог +`[HTTP] Webhook URL не задан — отправка пропущена`. + +Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM, +поэтому сброс Wi-Fi настройки из этой таблицы не затрагивает. --- @@ -209,16 +288,45 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о | Маршрут | Метод | Описание | |---|---|---| | `/` | `GET` | HTML-панель мониторинга | -| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0}` | +| `/status` | `GET` | JSON `{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}` | | `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться | -| `/set_webhook` | `POST` | Параметр `url` (≤220 симв.), сохранить в EEPROM | +| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` | -Панель опрашивает `/status` каждые 2 секунды и перезагружает страницу при -изменении состояния. Шрифты подгружаются с Google Fonts — при отсутствии -интернета у клиента интерфейс отрисуется системным моноширинным шрифтом. +### 7.1 `/set_config` -Аутентификации нет: любой в той же сети может сбросить Wi-Fi и подменить -webhook. Контроллер рассчитан на изолированный технологический сегмент. +Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** — +так частично заполненная форма не обнуляет остальные параметры. Запись в EEPROM +выполняется только если что-то реально изменилось. + +| Параметр | Валидация | При отказе | +|---|---|---| +| `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 +и изменить выдержки. Контроллер рассчитан на изолированный технологический +сегмент. --- @@ -246,13 +354,27 @@ STATUS: OK | работает резерв | `WARN: BACKUP RUNNING` | | иначе | `STATUS: OK` | +Состояние реле на экран не выводится — свободной строки в макете нет. Реле видны +только в веб-панели и, для «Резерва», по встроенному светодиоду платы на `D4`. + --- -## 9. Кнопка сброса Wi-Fi +## 9. Сброс настроек Wi-Fi -Пин `D3`, удержание `BTN_HOLD_MS = 3000 мс`. Во время удержания на дисплее -рисуется прогресс-бар с обратным отсчётом. По достижении порога — -`wm.resetSettings()` и `ESP.restart()`. Отпускание до порога отменяет операцию. +Физической кнопки сброса нет — она удалена осознанно. При сохранённых учётных +данных и пропавшей сети `wm.autoConnect()` не подключается и **сам** поднимает +портал `Pump_Control_Set`, поэтому для этого сценария кнопка не нужна. + +Остаётся два способа: + +* кнопка **«Сбросить Wi-Fi»** на веб-панели (`POST /reset_wifi`) — работает, пока + прибор в сети и панель доступна; +* выключить роутер и перезагрузить прибор — `autoConnect()` не найдёт сеть и + откроет портал. + +Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо +перевести в другую сеть»: простая перезагрузка портал не откроет, так как +подключение проходит успешно. --- 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 index 3a3502e..99fb05d 100644 --- 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 @@ -2,15 +2,29 @@ ============================================================ Контроллер насосов 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 = работает) - D8 — OUT_ALARM_MAIN (выход, HIGH = авария) - D0 — OUT_ALARM_EMERGENCY (выход, HIGH = авария; активен HIGH несмотря на pull-up к VCC) - BTN_WIFI_RESET = D3; // Кнопка сброса WiFi (INPUT_PULLUP, GND при нажатии) - D1 — OLED SDA (I2C) - D2 — OLED SCL (I2C) + 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) контакта реле. Библиотеки (установить через Library Manager): - Adafruit SSD1306 @@ -40,31 +54,45 @@ Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); bool oledOK = false; // --- КОНФИГУРАЦИЯ --- -const char* config_ap_name = "Pump_Control_Set"; -// Адрес webhook задаётся через веб-панель (/set_webhook) и хранится в EEPROM. +const char* config_ap_name = "Pump_Control_Set"; + +// Адрес webhook задаётся через веб-панель (/set_config) и хранится в EEPROM. // В исходнике намеренно пусто: URL содержит секретный токен. -const char* webhook_url_default = ""; +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 EEPROM_SIZE = 256; -const int EEPROM_MAGIC_ADDR = 0; -const uint16_t EEPROM_MAGIC = 0xA55A; -const int EEPROM_URL_ADDR = 2; -const int WEBHOOK_MAX_LEN = 220; +// Раскладка EEPROM. magic 0xA55B — версия 2; 0xA55A это версия 1 (только URL), +// она распознаётся и переносится, чтобы прошитые приборы не теряли webhook. +const int EEPROM_SIZE = 512; +const int EEPROM_MAGIC_ADDR = 0; +const uint16_t EEPROM_MAGIC_V2 = 0xA55B; +const uint16_t EEPROM_MAGIC_V1 = 0xA55A; +const int EEPROM_URL_ADDR = 2; +const int WEBHOOK_MAX_LEN = 220; +const int EEPROM_DEVICE_ADDR = 222; +const int DEVICE_MAX_LEN = 31; // +1 байт на '\0' = 32 байта поля +const int EEPROM_ALARM_ADDR = 254; +const int EEPROM_EMERG_ADDR = 256; -char webhook_url[WEBHOOK_MAX_LEN + 1]; +char webhook_url[WEBHOOK_MAX_LEN + 1]; +char device_name[DEVICE_MAX_LEN + 1]; +uint16_t alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; +uint16_t emergDelaySec = EMERG_DELAY_DEFAULT_SEC; // --- ПИНЫ --- -const int IN_PUMP_1 = D5; -const int IN_PUMP_2 = D6; -const int IN_PUMP_EMERGENCY = D7; -const int OUT_ALARM_MAIN = D8; // HIGH = авария -const int OUT_ALARM_EMERGENCY = D0; // HIGH = авария (D0 pull-up к VCC — при старте не тянет HIGH) -const int BTN_WIFI_RESET = D3; // Кнопка сброса WiFi (INPUT_PULLUP, GND при нажатии) +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 -// --- КНОПКА СБРОСА WiFi --- -const unsigned long BTN_HOLD_MS = 3000; // Удержание 3 сек для сброса -unsigned long btnPressStart = 0; -bool btnHolding = false; +// Логическое состояние реле. Держим отдельно от уровня на пине: выводить в +// панель digitalRead() с инверсной логикой — верный способ показать неправду. +bool relayMainOn = false; +bool relayEmergOn = false; ESP8266WebServer server(80); @@ -82,7 +110,6 @@ 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; -const unsigned long alarmDelay = 5000; // --- БЮДЖЕТ ОТПРАВКИ WEBHOOK --- // Полностью неблокирующий HTTPS на ESP8266 невозможен: BearSSL делает хендшейк @@ -99,6 +126,8 @@ bool emergencyActiveSent = false; bool systemReady = false; unsigned long mainAlarmStartTime = 0; bool mainAlarmPending = false; +unsigned long emergStartTime = 0; +bool emergPending = false; // Для обновления дисплея не чаще раза в секунду unsigned long lastDisplayUpdate = 0; @@ -107,29 +136,77 @@ const unsigned long DISPLAY_INTERVAL = 1000; // ============================================================ // EEPROM // ============================================================ -void eepromWriteUrl(const char* url) { - EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC); - for (int i = 0; i < WEBHOOK_MAX_LEN; i++) { - EEPROM.write(EEPROM_URL_ADDR + i, i < (int)strlen(url) ? url[i] : 0); +// Запись строки фиксированной длины с добивкой нулями. +static void eepromWriteStr(int addr, int fieldLen, const char* value) { + int len = (int)strlen(value); + for (int i = 0; i < fieldLen; i++) { + EEPROM.write(addr + i, i < len ? value[i] : 0); } - EEPROM.commit(); - Serial.println("[EEPROM] URL сохранён: " + String(url)); } -void eepromReadUrl() { +static void eepromReadStr(int addr, int fieldLen, char* dst) { + for (int i = 0; i < fieldLen; i++) dst[i] = EEPROM.read(addr + i); + dst[fieldLen] = '\0'; +} + +void eepromWriteConfig() { + EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_V2); + eepromWriteStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); + eepromWriteStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name); + EEPROM.put(EEPROM_ALARM_ADDR, alarmDelaySec); + EEPROM.put(EEPROM_EMERG_ADDR, emergDelaySec); + EEPROM.commit(); + Serial.printf("[EEPROM] Конфигурация сохранена: device=%s, авария=%u с, резерв=%u с\n", + device_name, alarmDelaySec, emergDelaySec); + Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); +} + +static void configSetDefaults() { + strncpy(webhook_url, WEBHOOK_URL_DEFAULT, WEBHOOK_MAX_LEN); + webhook_url[WEBHOOK_MAX_LEN] = '\0'; + strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); + device_name[DEVICE_MAX_LEN] = '\0'; + alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; + emergDelaySec = EMERG_DELAY_DEFAULT_SEC; +} + +void eepromReadConfig() { uint16_t magic; EEPROM.get(EEPROM_MAGIC_ADDR, magic); - if (magic == EEPROM_MAGIC) { - for (int i = 0; i < WEBHOOK_MAX_LEN; i++) { - webhook_url[i] = EEPROM.read(EEPROM_URL_ADDR + i); + + if (magic == EEPROM_MAGIC_V2) { + eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); + eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name); + EEPROM.get(EEPROM_ALARM_ADDR, alarmDelaySec); + EEPROM.get(EEPROM_EMERG_ADDR, emergDelaySec); + + // Защита от мусора во флеше: подставляем значения по умолчанию, а не + // уходим в заведомо неверную выдержку. + if (device_name[0] == '\0') { + strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); + device_name[DEVICE_MAX_LEN] = '\0'; } - webhook_url[WEBHOOK_MAX_LEN] = '\0'; - Serial.println("[EEPROM] URL загружен: " + String(webhook_url)); + if (alarmDelaySec > DELAY_MAX_SEC) alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; + if (emergDelaySec > DELAY_MAX_SEC) emergDelaySec = EMERG_DELAY_DEFAULT_SEC; + + Serial.printf("[EEPROM] Загружено: device=%s, авария=%u с, резерв=%u с\n", + device_name, alarmDelaySec, emergDelaySec); + Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); + + } else if (magic == EEPROM_MAGIC_V1) { + // Миграция v1 -> v2: URL сохраняем, новые поля получают значения по умолчанию. + eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); + strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); + device_name[DEVICE_MAX_LEN] = '\0'; + alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; + emergDelaySec = EMERG_DELAY_DEFAULT_SEC; + eepromWriteConfig(); + Serial.println(F("[EEPROM] Миграция v1 -> v2 выполнена, webhook сохранён.")); + } else { - strncpy(webhook_url, webhook_url_default, WEBHOOK_MAX_LEN); - webhook_url[WEBHOOK_MAX_LEN] = '\0'; - eepromWriteUrl(webhook_url); - Serial.println("[EEPROM] Первый старт. URL по умолчанию сохранён."); + configSetDefaults(); + eepromWriteConfig(); + Serial.println(F("[EEPROM] Первый старт, записаны значения по умолчанию.")); } } @@ -154,60 +231,14 @@ void eepromReadUrl() { */ // ============================================================ -// Кнопка сброса WiFi (удержание BTN_HOLD_MS мс) +// Реле аварий (модули active-LOW: LOW = включено) // ============================================================ -void handleResetButton() { - bool pressed = (digitalRead(BTN_WIFI_RESET) == LOW); - - if (pressed) { - if (!btnHolding) { - btnHolding = true; - btnPressStart = millis(); - } else { - unsigned long held = millis() - btnPressStart; - - // Показываем прогресс-бар на дисплее - if (oledOK && held < BTN_HOLD_MS) { - display.clearDisplay(); - display.setTextSize(1); - display.setTextColor(SSD1306_WHITE); - display.setCursor(10, 10); - display.print(F("Hold to reset WiFi")); - // Прогресс-бар - int barW = (int)((held * 108UL) / BTN_HOLD_MS); - display.drawRect(10, 28, 108, 12, SSD1306_WHITE); - display.fillRect(10, 28, barW, 12, SSD1306_WHITE); - // Счётчик секунд - display.setCursor(10, 46); - display.print((BTN_HOLD_MS - held) / 1000 + 1); - display.print(F(" sec...")); - display.display(); - } - - // Порог удержания достигнут — сброс - if (held >= BTN_HOLD_MS) { - Serial.println(F("[BTN] WiFi reset by button!")); - if (oledOK) { - display.clearDisplay(); - display.setCursor(20, 20); - display.print(F("WiFi RESET!")); - display.setCursor(10, 36); - display.print(F("Rebooting...")); - display.display(); - } - delay(1500); - WiFiManager wm; - wm.resetSettings(); - ESP.restart(); - } - } - } else { - // Кнопка отпущена до порога — сброс состояния - if (btnHolding) { - btnHolding = false; - lastDisplayUpdate = 0; // принудительное обновление дисплея - } - } +// Единственное место, где живёт инверсия уровня. Логическое состояние +// дублируется в переменную, чтобы веб-панель показывала смысл, а не уровень. +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() { @@ -366,7 +397,10 @@ void sendPostWebhook(String eventName, String status) { } http.addHeader("Content-Type", "application/json"); - String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + "\",\"device\":\"Wemos_D1_Pump\"}"; + // device_name отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование + // в JSON не требуется — сломать тело запроса ему нечем. + String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + + "\",\"device\":\"" + String(device_name) + "\"}"; int code = http.POST(body); if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started); @@ -375,6 +409,40 @@ void sendPostWebhook(String eventName, String status) { 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; +} + // ============================================================ // Дребезг + обновление стабильного состояния // ============================================================ @@ -397,8 +465,7 @@ void updatePumpState(Pump &p) { // HTML интерфейс (без изменений логики, только IP-адрес актуален) // ============================================================ String getHTML() { - bool isAlarm = (!p1.stableState && !p2.stableState); - bool isEmergency = pE.stableState; + bool isAlarm = (!p1.stableState && !p2.stableState); String html = F(""); html += ""; @@ -449,6 +516,17 @@ String getHTML() { ".cbtn-save{width:100%;padding:7px 0;background:rgba(46,160,67,0.15);border:1px solid rgba(46,160,67,0.3);border-radius:6px;color:#2ea043;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:0.1em;cursor:pointer}" ".emg-row{margin-top:10px;position:relative;z-index:1}" ".emg-panel{background:rgba(248,81,73,0.06);border:1px solid rgba(248,81,73,0.25);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px}" + ".relay-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px;position:relative;z-index:1}" + ".rpanel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}" + ".rpanel.on{border-color:rgba(46,160,67,0.35);background:rgba(46,160,67,0.06)}" + ".rpanel.on.emerg{border-color:rgba(218,54,51,0.4);background:rgba(218,54,51,0.06)}" + ".rstate{display:flex;align-items:center;gap:6px;margin-top:6px}" + ".cfg-row{margin-top:10px;position:relative;z-index:1}" + ".cfg-panel{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:10px;padding:12px 14px}" + ".cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}" + ".cfg-f{display:flex;flex-direction:column;gap:4px}" + ".cfg-l{font-size:9px;letter-spacing:0.08em;color:rgba(255,255,255,0.3);text-transform:uppercase}" + ".cfg-wide{grid-column:1 / -1}" "
"); html += F("
" @@ -505,7 +583,39 @@ String getHTML() { } html += "
Управление
"; - html += "
Webhook URL
" + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + // --- Состояние реле --- + html += "
"; + html += "
"; + html += F("
Реле «Авария»
"); + html += "
"; + html += "
" + + String(relayMainOn ? "Включено" : "Выключено") + "
"; + + html += "
"; + html += F("
Реле «Резерв»
"); + html += "
"; + html += "
" + + String(relayEmergOn ? "Включено" : "Выключено") + "
"; + html += "
"; + + // --- Настройки --- + html += F("
Настройки
"); + html += "
Webhook: " + + String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + html += F("
"); + html += F("
Webhook URL
" + "
"); + html += "
Имя устройства (A-Z a-z 0-9 _ -)
" + "
"; + html += "
Выдержка аварии, с
" + "
"; + html += "
Выдержка резерва, с
" + "
"; + html += F("
" + "
"); if (pE.stableState) { html += "
WARN · BACKUP
Работает резерв!
"; @@ -561,16 +671,21 @@ void setup() { } EEPROM.begin(EEPROM_SIZE); - eepromReadUrl(); + eepromReadConfig(); pinMode(p1.pin, INPUT_PULLUP); pinMode(p2.pin, INPUT_PULLUP); pinMode(pE.pin, INPUT_PULLUP); - pinMode(BTN_WIFI_RESET, 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); - digitalWrite(OUT_ALARM_MAIN, LOW); - digitalWrite(OUT_ALARM_EMERGENCY, LOW); + setRelay(OUT_ALARM_MAIN, false); + setRelay(OUT_ALARM_EMERGENCY, false); Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi...")); @@ -580,9 +695,11 @@ void setup() { }); 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 += "\"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); }); @@ -591,17 +708,45 @@ void setup() { delay(2000); WiFiManager wm; wm.resetSettings(); ESP.restart(); }); - server.on("/set_webhook", HTTP_POST, []() { - if (server.hasArg("url") && server.arg("url").length() > 0) { - String newUrl = server.arg("url"); newUrl.trim(); - if (newUrl.length() <= WEBHOOK_MAX_LEN) { - newUrl.toCharArray(webhook_url, WEBHOOK_MAX_LEN + 1); - eepromWriteUrl(webhook_url); - server.sendHeader("Location", "/"); server.send(303); + // Настройки одной формой. Пустое или неверное поле означает «не менять» — + // так частично заполненная форма не сбрасывает остальные параметры. + 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(webhook_url, WEBHOOK_MAX_LEN + 1); + changed = true; } - } else { - server.sendHeader("Location", "/"); server.send(303); } + if (server.hasArg("device")) { + String v = sanitizeDeviceName(server.arg("device")); + if (v.length() > 0 && v != String(device_name)) { + v.toCharArray(device_name, DEVICE_MAX_LEN + 1); + changed = true; + } + } + if (server.hasArg("alarm_delay")) { + uint16_t v; + if (parseDelaySec(server.arg("alarm_delay"), v) && v != alarmDelaySec) { + alarmDelaySec = v; + changed = true; + } + } + if (server.hasArg("emerg_delay")) { + uint16_t v; + if (parseDelaySec(server.arg("emerg_delay"), v) && v != emergDelaySec) { + emergDelaySec = v; + changed = true; + } + } + + if (changed) eepromWriteConfig(); + else Serial.println(F("[CONFIG] Изменений нет, запись в EEPROM пропущена")); + + server.sendHeader("Location", "/"); + server.send(303); }); // WiFiManager с заставкой на OLED @@ -655,7 +800,6 @@ void setup() { // ============================================================ void loop() { server.handleClient(); - handleResetButton(); updatePumpState(p1); updatePumpState(p2); @@ -675,13 +819,15 @@ void loop() { } // --- Основная авария --- + // Выдержка одна на реле и вебхук: при alarmDelaySec = 0 условие выполняется + // на следующей же итерации, то есть реакция мгновенная. bool bothStopped = (!p1.stableState && !p2.stableState); if (bothStopped) { if (!mainAlarmPending) { mainAlarmPending = true; mainAlarmStartTime = millis(); - } else if (millis() - mainAlarmStartTime >= alarmDelay) { - digitalWrite(OUT_ALARM_MAIN, HIGH); + } else if (millis() - mainAlarmStartTime >= (unsigned long)alarmDelaySec * 1000UL) { + setRelay(OUT_ALARM_MAIN, true); if (!mainAlarmSent) { Serial.println(F("[ALERT] Авария основных насосов")); sendPostWebhook("MAIN PUMPS", "ALARM"); @@ -689,7 +835,7 @@ void loop() { } } } else { - digitalWrite(OUT_ALARM_MAIN, LOW); + setRelay(OUT_ALARM_MAIN, false); mainAlarmPending = false; if (mainAlarmSent) { Serial.println(F("[INFO] Восстановление: основные насосы OK")); @@ -699,16 +845,26 @@ void loop() { } // --- Резервный насос --- - if (pE.stableState && !emergencyActiveSent) { - digitalWrite(OUT_ALARM_EMERGENCY, HIGH); - Serial.println(F("[WARN] Запущен экстренный насос")); - sendPostWebhook("EMERGENCY PUMP", "STARTED"); - emergencyActiveSent = true; - } else if (!pE.stableState && emergencyActiveSent) { - digitalWrite(OUT_ALARM_EMERGENCY, LOW); - Serial.println(F("[INFO] Резерв остановлен")); - sendPostWebhook("EMERGENCY PUMP", "STOPPED"); - emergencyActiveSent = false; + if (pE.stableState) { + if (!emergPending) { + emergPending = true; + emergStartTime = millis(); + } else if (millis() - emergStartTime >= (unsigned long)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; + } } // --- Обновление дисплея раз в секунду --- From fa54e5790fd9f2d762ae6626bdc265c762565bb6 Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 16:29:47 +0700 Subject: [PATCH 05/12] =?UTF-8?q?=D0=94=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20EEPROM:=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20commit()=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Настройки не сохраняются после перезагрузки. Причина пока не установлена, это шаг сбора улик, а не исправление. Найден дефект, мешавший диагностике: результат EEPROM.commit() игнорировался, поэтому лог печатал «Конфигурация сохранена» безусловно — даже при провалившейся записи. Теперь запись подтверждается повторным чтением сектора из флеша и сравнением всех полей; при расхождении в лог идут записанные и прочитанные значения. Добавлена проверка геометрии флеша при старте: несовпадение размера по настройке IDE с физическим уводит сектор EEPROM за пределы микросхемы, и запись молча теряется — симптом ровно тот же. Проверкой чтения исключены: корректность библиотеки EEPROM, раскладка адресов (258 из 512, без перекрытий), стирание сектора со стороны WiFiManager::resetSettings() — он вызывает только WiFi.disconnect(). Co-Authored-By: Claude Opus 5 --- .../pump_controller_8_2_OLED_DONE.ino | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) 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 index 99fb05d..fc6dc87 100644 --- 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 @@ -149,16 +149,57 @@ static void eepromReadStr(int addr, int fieldLen, char* dst) { dst[fieldLen] = '\0'; } -void eepromWriteConfig() { +// Возвращает true только если запись подтверждена перечитыванием из флеша. +// Проверка нужна потому, что EEPROM.commit() умеет вернуть false (например при +// неверно выбранном размере флеша в IDE), и молчаливая потеря настроек выглядит +// точно так же, как «не сохраняется». +bool eepromWriteConfig() { EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_V2); eepromWriteStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); eepromWriteStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name); EEPROM.put(EEPROM_ALARM_ADDR, alarmDelaySec); EEPROM.put(EEPROM_EMERG_ADDR, emergDelaySec); - EEPROM.commit(); - Serial.printf("[EEPROM] Конфигурация сохранена: device=%s, авария=%u с, резерв=%u с\n", + + if (!EEPROM.commit()) { + Serial.println(F("[EEPROM] ОШИБКА: commit() вернул false, настройки НЕ сохранены")); + return false; + } + + // Повторный begin() перечитывает сектор из флеша в буфер — это и есть проверка, + // что записанное действительно легло на флеш, а не только в ОЗУ. + EEPROM.begin(EEPROM_SIZE); + + uint16_t magicChk, alarmChk, emergChk; + char urlChk[WEBHOOK_MAX_LEN + 1]; + char devChk[DEVICE_MAX_LEN + 1]; + EEPROM.get(EEPROM_MAGIC_ADDR, magicChk); + eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, urlChk); + eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, devChk); + EEPROM.get(EEPROM_ALARM_ADDR, alarmChk); + EEPROM.get(EEPROM_EMERG_ADDR, emergChk); + + bool ok = (magicChk == EEPROM_MAGIC_V2) && + (strcmp(urlChk, webhook_url) == 0) && + (strcmp(devChk, device_name) == 0) && + (alarmChk == alarmDelaySec) && + (emergChk == emergDelaySec); + + if (!ok) { + Serial.println(F("[EEPROM] ОШИБКА: запись не подтвердилась при перечитывании!")); + Serial.printf("[EEPROM] magic: записан 0x%04X, прочитан 0x%04X\n", + EEPROM_MAGIC_V2, magicChk); + Serial.printf("[EEPROM] device: записан '%s', прочитан '%s'\n", device_name, devChk); + Serial.printf("[EEPROM] выдержки: записаны %u/%u, прочитаны %u/%u\n", + alarmDelaySec, emergDelaySec, alarmChk, emergChk); + Serial.println("[EEPROM] URL записан: " + String(webhook_url)); + Serial.println("[EEPROM] URL прочитан: " + String(urlChk)); + return false; + } + + Serial.printf("[EEPROM] Сохранено и проверено: device=%s, авария=%u с, резерв=%u с\n", device_name, alarmDelaySec, emergDelaySec); Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); + return true; } static void configSetDefaults() { @@ -670,6 +711,21 @@ void setup() { 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()); + if (flashIde != flashReal) { + Serial.println(F("[FLASH] ВНИМАНИЕ: выберите в IDE верный размер флеша,")); + Serial.println(F("[FLASH] иначе EEPROM не сохраняется.")); + } + EEPROM.begin(EEPROM_SIZE); eepromReadConfig(); From ca02a48684eec70372543faacc1c996cddab8bc8 Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 17:21:25 +0700 Subject: [PATCH 06/12] =?UTF-8?q?=D0=94=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B0:=20=D0=BF=D0=B5=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20=D1=81=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=20EEPROM=20=D0=B8=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20FS=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20=D1=84=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D1=84?= =?UTF-8?q?=D0=BB=D0=B5=D1=88=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit() возвращает false, то есть падает сам вызов SDK (spi_flash_erase_sector либо spi_flash_write). Самая вероятная причина — сектор EEPROM за пределами физической микросхемы. При раскладке 4MB/FS:2MB сектор EEPROM это 1019 (смещение 0x3FB000), что требует чипа не меньше 4 МБ. Теперь лог сам печатает это смещение и сравнивает с ESP.getFlashChipRealSize(), а также границы файловой системы — последнее важно, потому что при том же дефекте LittleFS по адресу 0x400000 окажется недоступен ровно так же. Co-Authored-By: Claude Opus 5 --- .../pump_controller_8_2_OLED_DONE.ino | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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 index fc6dc87..6b19a40 100644 --- 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 @@ -41,6 +41,7 @@ #include #include #include +#include // EEPROM_start / FS_PHYS_ADDR — только для диагностики #include #include @@ -721,9 +722,24 @@ void setup() { flashIde == flashReal ? "совпадает" : "!!! НЕ СОВПАДАЕТ !!!"); Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n", ESP.getSketchSize(), ESP.getFreeSketchSpace()); + + // Куда именно EEPROM собирается писать. Если это смещение выходит за размер + // физической микросхемы, spi_flash_erase_sector() провалится и commit() + // вернёт false — то есть настройки не сохранятся никогда. + uint32_t eepromOffset = EEPROM_start - 0x40200000; + Serial.printf("[FLASH] EEPROM пишет в сектор %u (смещение 0x%06X)%s\n", + eepromOffset / SPI_FLASH_SEC_SIZE, eepromOffset, + eepromOffset + SPI_FLASH_SEC_SIZE <= flashReal + ? "" : " <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!"); + 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] иначе EEPROM не сохраняется.")); + Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует")); + Serial.println(F("[FLASH] микросхеме. Ни EEPROM, ни LittleFS работать не будут,")); + Serial.println(F("[FLASH] пока в Tools -> Flash Size не выбран верный размер.")); } EEPROM.begin(EEPROM_SIZE); From 4c0f9672f3be110f682065230c523bc2e6b5253f Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 19:41:05 +0700 Subject: [PATCH 07/12] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=D0=B7=20EEPROM=20=D0=B2=20LittleFS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 8 +- pump_controller_8_2_OLED_DONE/README.md | 128 +++++-- .../pump_controller_8_2_OLED_DONE.ino | 329 ++++++++++-------- 3 files changed, 277 insertions(+), 188 deletions(-) diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index ff27499..ca07538 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -169,8 +169,12 @@ STATUS: OK | **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` | | **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) | -Настройки сохраняются в памяти прибора, переживают отключение питания и сброс -Wi-Fi. +Настройки сохраняются в файл во внутренней памяти прибора, переживают +отключение питания и сброс Wi-Fi. + +> Если панель показывает красное предупреждение «Файловая система недоступна», +> настройки работают только до перезагрузки. Причина почти всегда в настройке +> `Flash Size` при прошивке — нужен вариант с файловой системой, не `FS:none`. > Русские буквы и пробелы в имени устройства **не сохранятся** — они будут > отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки. diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index cc9ec82..8fa026a 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -105,11 +105,16 @@ SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (констан * Adafruit SSD1306 * Adafruit GFX Library * WiFiManager (tzapu) +* ArduinoJson (v7) Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`, -`WiFiClientSecure`, `EEPROM`, `Wire`. +`WiFiClientSecure`, `LittleFS`, `Wire`. -Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600, Flash size 4MB. +Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600. + +**Flash Size обязательно с файловой системой** — например +`4MB (FS:2MB OTA:~1019KB)`. При `FS:none` настройки сохранять некуда; прошивка +сообщит об этом в лог при старте и покажет предупреждение в веб-панели. --- @@ -180,7 +185,7 @@ pE = стоит -> реле «Резерв» выключено ## 5. Webhook -`POST` на `webhook_url`, `Content-Type: application/json`: +`POST` на `settings.webhookUrl`, `Content-Type: application/json`: ```json {"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"} @@ -191,7 +196,7 @@ pE = стоит -> реле «Резерв» выключено | `MAIN PUMPS` | `ALARM` / `OK` | | `EMERGENCY PUMP` | `STARTED` / `STOPPED` | -Поле `device` берётся из настройки `device_name` (задаётся через веб-панель, +Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель, по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до `[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело запроса ему нечем. @@ -241,44 +246,99 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о --- -## 6. Хранение настроек (EEPROM) +## 6. Хранение настроек (LittleFS) -Эмулируемая EEPROM, 512 байт. Занято 258. +Настройки лежат в файловой системе LittleFS, в файле `/settings.json`: -| Адрес | Размер | Содержимое | По умолчанию | -|---|---|---|---| -| `0` | 2 | Magic `0xA55B` — версия раскладки 2 | — | -| `2` | 220 | `webhook_url`, `\0`-терминированный | пусто | -| `222` | 31 | `device_name` | `Wemos_D1_Pump` | -| `254` | 2 | `alarm_delay_sec` (`uint16`) | `5` | -| `256` | 2 | `emerg_delay_sec` (`uint16`) | `0` | +```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` | -### 6.1 Версионирование и миграция +Имена ключей вынесены в константы `KEY_*` и используются и при чтении, и при +записи — рассинхронизация имён между load и save самый частый источник багов в +таком коде, а через константу компилятор их разойтись не даст. -Magic одновременно служит номером версии раскладки: +Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без +перезагрузки, и глобальные `String` фрагментировали бы кучу. -| Прочитанный magic | Действие | -|---|---| -| `0xA55B` | читаются все поля | -| `0xA55A` | **миграция v1 → v2**: `webhook_url` сохраняется, новые поля получают значения по умолчанию, magic перезаписывается | -| иное | первый старт, все поля по умолчанию | +### 6.1 Почему не EEPROM -Миграция нужна, чтобы уже прошитые приборы не потеряли настроенный webhook при -обновлении прошивки. +Изначально настройки хранились в эмулируемой EEPROM. На целевой плате это **не +работает**: `EEPROM.commit()` возвращает `false`, то есть падает сам вызов SDK +(`spi_flash_erase_sector` либо `spi_flash_write`), и настройки молча теряются. -При чтении v2 значения дополнительно проверяются: пустое `device_name` и -выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию — иначе мусор -во флеше дал бы заведомо неверную выдержку. +Проверкой исходников исключены: ошибка в библиотеке EEPROM, перекрытие адресов, +стирание сектора со стороны `WiFiManager::resetSettings()`. Физический размер +флеша тоже ни при чём — LittleFS на том же чипе работает. -Пустой `webhook_url` — легальное состояние. Пока URL не задан, +Наиболее вероятный механизм: при раскладке `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 не задан — отправка пропущена`. -Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM, -поэтому сброс Wi-Fi настройки из этой таблицы не затрагивает. - --- ## 7. Веб-интерфейс @@ -295,7 +355,7 @@ Magic одновременно служит номером версии раск ### 7.1 `/set_config` Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** — -так частично заполненная форма не обнуляет остальные параметры. Запись в EEPROM +так частично заполненная форма не обнуляет остальные параметры. Запись в файл выполняется только если что-то реально изменилось. | Параметр | Валидация | При отказе | @@ -403,9 +463,9 @@ STATUS: OK портал придётся своим таймером. * Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией не считается и никак не сигнализируется. -* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем, - кто может её открыть. Если URL содержит секретный токен — доступ к панели - равносилен доступу к токену. +* Webhook URL хранится в `/settings.json` в открытом виде и отдаётся + веб-панелью всем, кто может её открыть. Если URL содержит секретный токен — + доступ к панели равносилен доступу к токену. --- 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 index 6b19a40..8b7a68a 100644 --- 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 @@ -26,11 +26,18 @@ При необходимости решается на монтаже — нагрузка снимается с нормально замкнутого (NC) контакта реле. + Настройки хранятся в LittleFS, файл /settings.json. + Библиотеки (установить через Library Manager): - Adafruit SSD1306 - Adafruit GFX Library - WiFiManager (tzapu) - - ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient (часть ESP8266 core) + - ArduinoJson (v7) + - ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient / LittleFS + (часть ESP8266 core) + + В IDE обязателен вариант Flash Size с ненулевой файловой системой, + например «4MB (FS:2MB OTA:~1019KB)» — иначе настройки сохранять некуда. ============================================================ */ @@ -39,9 +46,10 @@ #include #include #include -#include +#include +#include #include -#include // EEPROM_start / FS_PHYS_ADDR — только для диагностики +#include // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики #include #include @@ -57,31 +65,42 @@ bool oledOK = false; // --- КОНФИГУРАЦИЯ --- const char* config_ap_name = "Pump_Control_Set"; -// Адрес webhook задаётся через веб-панель (/set_config) и хранится в EEPROM. -// В исходнике намеренно пусто: URL содержит секретный токен. -const char* WEBHOOK_URL_DEFAULT = ""; -const char* DEVICE_NAME_DEFAULT = "Wemos_D1_Pump"; +// --- ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ --- +// 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; -// Раскладка EEPROM. magic 0xA55B — версия 2; 0xA55A это версия 1 (только URL), -// она распознаётся и переносится, чтобы прошитые приборы не теряли webhook. -const int EEPROM_SIZE = 512; -const int EEPROM_MAGIC_ADDR = 0; -const uint16_t EEPROM_MAGIC_V2 = 0xA55B; -const uint16_t EEPROM_MAGIC_V1 = 0xA55A; -const int EEPROM_URL_ADDR = 2; -const int WEBHOOK_MAX_LEN = 220; -const int EEPROM_DEVICE_ADDR = 222; -const int DEVICE_MAX_LEN = 31; // +1 байт на '\0' = 32 байта поля -const int EEPROM_ALARM_ADDR = 254; -const int EEPROM_EMERG_ADDR = 256; +const int WEBHOOK_MAX_LEN = 220; +const int DEVICE_MAX_LEN = 31; -char webhook_url[WEBHOOK_MAX_LEN + 1]; -char device_name[DEVICE_MAX_LEN + 1]; -uint16_t alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; -uint16_t emergDelaySec = EMERG_DELAY_DEFAULT_SEC; +// --- ХРАНИЛИЩЕ НАСТРОЕК --- +// LittleFS, а не EEPROM: запись в сектор EEPROM на этой плате проваливается +// (commit() возвращает false), тогда как файловая система на том же чипе +// работает. Подробности — в README, раздел «Хранение настроек». +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; @@ -135,120 +154,125 @@ unsigned long lastDisplayUpdate = 0; const unsigned long DISPLAY_INTERVAL = 1000; // ============================================================ -// EEPROM +// НАСТРОЙКИ (LittleFS + JSON) // ============================================================ -// Запись строки фиксированной длины с добивкой нулями. -static void eepromWriteStr(int addr, int fieldLen, const char* value) { - int len = (int)strlen(value); - for (int i = 0; i < fieldLen; i++) { - EEPROM.write(addr + i, i < len ? value[i] : 0); + +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; } -static void eepromReadStr(int addr, int fieldLen, char* dst) { - for (int i = 0; i < fieldLen; i++) dst[i] = EEPROM.read(addr + i); - dst[fieldLen] = '\0'; -} - -// Возвращает true только если запись подтверждена перечитыванием из флеша. -// Проверка нужна потому, что EEPROM.commit() умеет вернуть false (например при -// неверно выбранном размере флеша в IDE), и молчаливая потеря настроек выглядит -// точно так же, как «не сохраняется». -bool eepromWriteConfig() { - EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_V2); - eepromWriteStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); - eepromWriteStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name); - EEPROM.put(EEPROM_ALARM_ADDR, alarmDelaySec); - EEPROM.put(EEPROM_EMERG_ADDR, emergDelaySec); - - if (!EEPROM.commit()) { - Serial.println(F("[EEPROM] ОШИБКА: commit() вернул false, настройки НЕ сохранены")); +// Загрузка отказоустойчива: отсутствующий или битый ключ оставляет значение, +// уже проставленное settingsDefaults(). Благодаря этому файл, записанный +// прошлой версией прошивки, не ломается при добавлении новых полей. +static bool settingsLoad() { + File f = LittleFS.open(SETTINGS_PATH, "r"); + if (!f) { + Serial.println(F("[SET] Файл настроек не найден")); return false; } - // Повторный begin() перечитывает сектор из флеша в буфер — это и есть проверка, - // что записанное действительно легло на флеш, а не только в ОЗУ. - EEPROM.begin(EEPROM_SIZE); + JsonDocument doc; + DeserializationError err = deserializeJson(doc, f); + f.close(); - uint16_t magicChk, alarmChk, emergChk; - char urlChk[WEBHOOK_MAX_LEN + 1]; - char devChk[DEVICE_MAX_LEN + 1]; - EEPROM.get(EEPROM_MAGIC_ADDR, magicChk); - eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, urlChk); - eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, devChk); - EEPROM.get(EEPROM_ALARM_ADDR, alarmChk); - EEPROM.get(EEPROM_EMERG_ADDR, emergChk); - - bool ok = (magicChk == EEPROM_MAGIC_V2) && - (strcmp(urlChk, webhook_url) == 0) && - (strcmp(devChk, device_name) == 0) && - (alarmChk == alarmDelaySec) && - (emergChk == emergDelaySec); - - if (!ok) { - Serial.println(F("[EEPROM] ОШИБКА: запись не подтвердилась при перечитывании!")); - Serial.printf("[EEPROM] magic: записан 0x%04X, прочитан 0x%04X\n", - EEPROM_MAGIC_V2, magicChk); - Serial.printf("[EEPROM] device: записан '%s', прочитан '%s'\n", device_name, devChk); - Serial.printf("[EEPROM] выдержки: записаны %u/%u, прочитаны %u/%u\n", - alarmDelaySec, emergDelaySec, alarmChk, emergChk); - Serial.println("[EEPROM] URL записан: " + String(webhook_url)); - Serial.println("[EEPROM] URL прочитан: " + String(urlChk)); + if (err) { + Serial.printf("[SET] ОШИБКА разбора JSON: %s\n", err.c_str()); return false; } - Serial.printf("[EEPROM] Сохранено и проверено: device=%s, авария=%u с, резерв=%u с\n", - device_name, alarmDelaySec, emergDelaySec); - Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); + 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; } -static void configSetDefaults() { - strncpy(webhook_url, WEBHOOK_URL_DEFAULT, WEBHOOK_MAX_LEN); - webhook_url[WEBHOOK_MAX_LEN] = '\0'; - strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); - device_name[DEVICE_MAX_LEN] = '\0'; - alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; - emergDelaySec = EMERG_DELAY_DEFAULT_SEC; +// Перезаписывает файл целиком. 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; } -void eepromReadConfig() { - uint16_t magic; - EEPROM.get(EEPROM_MAGIC_ADDR, magic); +// Монтирует ФС и заполняет settings. Вызывать первым из всего, что работает +// с LittleFS: повторный LittleFS.begin() в других местах не нужен. +void settingsBegin() { + settingsDefaults(); - if (magic == EEPROM_MAGIC_V2) { - eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); - eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name); - EEPROM.get(EEPROM_ALARM_ADDR, alarmDelaySec); - EEPROM.get(EEPROM_EMERG_ADDR, emergDelaySec); - - // Защита от мусора во флеше: подставляем значения по умолчанию, а не - // уходим в заведомо неверную выдержку. - if (device_name[0] == '\0') { - strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); - device_name[DEVICE_MAX_LEN] = '\0'; + if (!LittleFS.begin()) { + Serial.println(F("[FS] Не смонтирована, форматирую...")); + if (!LittleFS.format() || !LittleFS.begin()) { + Serial.println(F("[FS] ОШИБКА: файловая система недоступна.")); + Serial.println(F("[FS] Прибор работает, но настройки не переживут перезагрузку.")); + settingsFsReady = false; + return; } - if (alarmDelaySec > DELAY_MAX_SEC) alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; - if (emergDelaySec > DELAY_MAX_SEC) emergDelaySec = EMERG_DELAY_DEFAULT_SEC; + Serial.println(F("[FS] Отформатирована успешно")); + } + settingsFsReady = true; - Serial.printf("[EEPROM] Загружено: device=%s, авария=%u с, резерв=%u с\n", - device_name, alarmDelaySec, emergDelaySec); - Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); + FSInfo info; + if (LittleFS.info(info)) { + Serial.printf("[FS] Смонтирована: всего %u Б, занято %u Б\n", + (unsigned)info.totalBytes, (unsigned)info.usedBytes); + } - } else if (magic == EEPROM_MAGIC_V1) { - // Миграция v1 -> v2: URL сохраняем, новые поля получают значения по умолчанию. - eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); - strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); - device_name[DEVICE_MAX_LEN] = '\0'; - alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; - emergDelaySec = EMERG_DELAY_DEFAULT_SEC; - eepromWriteConfig(); - Serial.println(F("[EEPROM] Миграция v1 -> v2 выполнена, webhook сохранён.")); - - } else { - configSetDefaults(); - eepromWriteConfig(); - Serial.println(F("[EEPROM] Первый старт, записаны значения по умолчанию.")); + if (!settingsLoad()) { + Serial.println(F("[SET] Создаю файл со значениями по умолчанию")); + settingsSave(); } } @@ -383,7 +407,7 @@ static bool parseWebhookHost(const char* url, String &host) { } void sendPostWebhook(String eventName, String status) { - if (webhook_url[0] == '\0') { + if (settings.webhookUrl[0] == '\0') { Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена")); return; } @@ -395,8 +419,8 @@ void sendPostWebhook(String eventName, String status) { const unsigned long started = millis(); String host; - if (!parseWebhookHost(webhook_url, host)) { - Serial.println("[HTTP] Не удалось разобрать URL: " + String(webhook_url)); + if (!parseWebhookHost(settings.webhookUrl, host)) { + Serial.println("[HTTP] Не удалось разобрать URL: " + String(settings.webhookUrl)); return; } @@ -431,18 +455,18 @@ void sendPostWebhook(String eventName, String status) { http.setReuse(false); // не держать TLS-сокет после отправки Serial.print(F("[HTTP] POST: ")); - Serial.println(webhook_url); + Serial.println(settings.webhookUrl); - if (!http.begin(client, webhook_url)) { + if (!http.begin(client, settings.webhookUrl)) { Serial.println(F("[HTTP] begin() не удался")); return; } http.addHeader("Content-Type", "application/json"); - // device_name отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование + // settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование // в JSON не требуется — сломать тело запроса ему нечем. String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + - "\",\"device\":\"" + String(device_name) + "\"}"; + "\",\"device\":\"" + String(settings.deviceName) + "\"}"; int code = http.POST(body); if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started); @@ -642,20 +666,24 @@ String getHTML() { // --- Настройки --- html += F("
Настройки
"); + if (!settingsFsReady) { + html += F("
" + "Файловая система недоступна — настройки не переживут перезагрузку!
"); + } html += "
Webhook: " + - String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "
"; + String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "
"; html += F("
"); html += F("
Webhook URL
" "
"); html += "
Имя устройства (A-Z a-z 0-9 _ -)
" - "
"; html += "
Выдержка аварии, с
" "
"; + String(settings.alarmDelaySec) + "'>
"; html += "
Выдержка резерва, с
" "
"; + String(settings.emergDelaySec) + "'>
"; html += F("
" ""); @@ -723,14 +751,8 @@ void setup() { Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n", ESP.getSketchSize(), ESP.getFreeSketchSpace()); - // Куда именно EEPROM собирается писать. Если это смещение выходит за размер - // физической микросхемы, spi_flash_erase_sector() провалится и commit() - // вернёт false — то есть настройки не сохранятся никогда. - uint32_t eepromOffset = EEPROM_start - 0x40200000; - Serial.printf("[FLASH] EEPROM пишет в сектор %u (смещение 0x%06X)%s\n", - eepromOffset / SPI_FLASH_SEC_SIZE, eepromOffset, - eepromOffset + SPI_FLASH_SEC_SIZE <= flashReal - ? "" : " <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!"); + // Где лежит файловая система. Если область выходит за размер микросхемы, + // LittleFS не смонтируется и настройки сохранять будет некуда. Serial.printf("[FLASH] файловая система: смещение 0x%06X, размер %u Б%s\n", FS_PHYS_ADDR, FS_PHYS_SIZE, FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal @@ -738,12 +760,15 @@ void setup() { if (flashIde != flashReal) { Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует")); - Serial.println(F("[FLASH] микросхеме. Ни EEPROM, ни LittleFS работать не будут,")); - Serial.println(F("[FLASH] пока в Tools -> Flash Size не выбран верный размер.")); + 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] Нужен вариант с ненулевой файловой системой.")); } - EEPROM.begin(EEPROM_SIZE); - eepromReadConfig(); + settingsBegin(); pinMode(p1.pin, INPUT_PULLUP); pinMode(p2.pin, INPUT_PULLUP); @@ -788,34 +813,34 @@ void setup() { if (server.hasArg("url")) { String v = server.arg("url"); v.trim(); if (v.length() > 0 && v.length() <= WEBHOOK_MAX_LEN) { - v.toCharArray(webhook_url, WEBHOOK_MAX_LEN + 1); + 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(device_name)) { - v.toCharArray(device_name, DEVICE_MAX_LEN + 1); + 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 != alarmDelaySec) { - alarmDelaySec = 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 != emergDelaySec) { - emergDelaySec = v; + if (parseDelaySec(server.arg("emerg_delay"), v) && v != settings.emergDelaySec) { + settings.emergDelaySec = v; changed = true; } } - if (changed) eepromWriteConfig(); - else Serial.println(F("[CONFIG] Изменений нет, запись в EEPROM пропущена")); + if (changed) settingsSave(); + else Serial.println(F("[SET] Изменений нет, запись пропущена")); server.sendHeader("Location", "/"); server.send(303); @@ -891,14 +916,14 @@ void loop() { } // --- Основная авария --- - // Выдержка одна на реле и вебхук: при alarmDelaySec = 0 условие выполняется + // Выдержка одна на реле и вебхук: при settings.alarmDelaySec = 0 условие выполняется // на следующей же итерации, то есть реакция мгновенная. bool bothStopped = (!p1.stableState && !p2.stableState); if (bothStopped) { if (!mainAlarmPending) { mainAlarmPending = true; mainAlarmStartTime = millis(); - } else if (millis() - mainAlarmStartTime >= (unsigned long)alarmDelaySec * 1000UL) { + } else if (millis() - mainAlarmStartTime >= (unsigned long)settings.alarmDelaySec * 1000UL) { setRelay(OUT_ALARM_MAIN, true); if (!mainAlarmSent) { Serial.println(F("[ALERT] Авария основных насосов")); @@ -921,7 +946,7 @@ void loop() { if (!emergPending) { emergPending = true; emergStartTime = millis(); - } else if (millis() - emergStartTime >= (unsigned long)emergDelaySec * 1000UL) { + } else if (millis() - emergStartTime >= (unsigned long)settings.emergDelaySec * 1000UL) { setRelay(OUT_ALARM_EMERGENCY, true); if (!emergencyActiveSent) { Serial.println(F("[WARN] Запущен экстренный насос")); From 963209dcc9e91d5baa3ca7a320bb1d0aaa3d535f Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 19:51:38 +0700 Subject: [PATCH 08/12] =?UTF-8?q?=D0=9D=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D0=B0:=20=D1=87?= =?UTF-8?q?=D0=B8=D0=BF=202=20=D0=9C=D0=91=20=D0=BF=D1=80=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B5=20IDE=204=20?= =?UTF-8?q?=D0=9C=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плата имеет физически 2 МБ флеша, в IDE было выбрано 4MB (FS:2MB). При этой раскладке сектор EEPROM лежит по смещению 0x3FB000, а начало LittleFS — по 0x400000, тогда как чип заканчивается на 0x200000. Обе области вне микросхемы, поэтому spi_flash_erase_sector() проваливался и EEPROM.commit() возвращал false. Лечится настройкой Flash Size = 2MB (FS:128KB OTA:~960KB), а не кодом: любая раскладка 2MB кладёт файловую систему ниже 0x1FB000, а EEPROM в сектор 511. С верной настройкой работали бы оба механизма хранения. Ранее записанная в README гипотеза про «щель между FS и областью SDK» была неверной и заменена подтверждённым разбором. Переход на LittleFS остаётся оправданным по своим качествам, но проблему решает настройка IDE — в документации это разделено явно, чтобы не ввести в заблуждение при похожем симптоме на другой плате. Документация исправлена: плата 2 МБ, а не 4 МБ; требование совпадения выбранного размера с физическим; предупреждение о потере учётных данных Wi-Fi при смене раскладки. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 8 +- pump_controller_8_2_OLED_DONE/README.md | 85 ++++++++++++++----- .../pump_controller_8_2_OLED_DONE.ino | 7 +- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index ca07538..4b45c05 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -172,9 +172,11 @@ STATUS: OK Настройки сохраняются в файл во внутренней памяти прибора, переживают отключение питания и сброс Wi-Fi. -> Если панель показывает красное предупреждение «Файловая система недоступна», -> настройки работают только до перезагрузки. Причина почти всегда в настройке -> `Flash Size` при прошивке — нужен вариант с файловой системой, не `FS:none`. +> Если панель показывает красное предупреждение «Файловая система недоступна» +> или настройки пропадают после перезагрузки — причина в настройке `Flash Size` +> при прошивке. Она обязана совпадать с фактическим размером памяти платы: +> для этих приборов **`2MB (FS:128KB OTA:~960KB)`**. Вариант `4MB` на плате с +> 2 МБ приводит ровно к такому симптому. > Русские буквы и пробелы в имени устройства **не сохранятся** — они будут > отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки. diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index 8fa026a..f82b037 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -23,7 +23,15 @@ ### 2.1 Плата -Wemos D1 Mini (ESP8266, 4 МБ Flash). Питание 5 В по microUSB или 3.3 В на пин `3V3`. +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 Распиновка @@ -112,9 +120,23 @@ SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (констан Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600. -**Flash Size обязательно с файловой системой** — например -`4MB (FS:2MB OTA:~1019KB)`. При `FS:none` настройки сохранять некуда; прошивка -сообщит об этом в лог при старте и покажет предупреждение в веб-панели. +**Flash Size: `2MB (FS:128KB OTA:~960KB)`** — под физические 2 МБ целевой платы. + +Два требования к этой настройке, оба обязательные: + +* **Размер должен совпадать с физическим чипом.** Выбранные 4 МБ на плате с 2 МБ + уводят и EEPROM, и файловую систему за границу микросхемы — настройки молча + не сохраняются (раздел 6.1). +* **Файловая система должна быть ненулевой.** При `FS:none` сохранять настройки + некуда; прошивка скажет об этом в лог при старте и покажет предупреждение в + веб-панели. + +Раскладка для 2 МБ (любой из вариантов `2MB (FS:...)`) кладёт файловую систему +ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа. + +После смены `Flash Size` заливайте прошивку заново: меняется заголовок +загрузчика и границы областей. Учтите, что **сохранённые учётные данные Wi-Fi +при этом теряются** и прибор поднимет портал `Pump_Control_Set`. --- @@ -268,26 +290,49 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без перезагрузки, и глобальные `String` фрагментировали бы кучу. -### 6.1 Почему не EEPROM +### 6.1 История: почему настройки не сохранялись -Изначально настройки хранились в эмулируемой EEPROM. На целевой плате это **не -работает**: `EEPROM.commit()` возвращает `false`, то есть падает сам вызов SDK -(`spi_flash_erase_sector` либо `spi_flash_write`), и настройки молча теряются. +Реальный случай, разобранный на этом проекте. Симптом: настройки и webhook +пропадают после перезагрузки. -Проверкой исходников исключены: ошибка в библиотеке EEPROM, перекрытие адресов, -стирание сектора со стороны `WiFiManager::resetSettings()`. Физический размер -флеша тоже ни при чём — LittleFS на том же чипе работает. +**Корневая причина — несовпадение размера флеша.** В IDE было выбрано +`4MB (FS:2MB OTA:~1019KB)`, тогда как физически на плате **2 МБ**: -Наиболее вероятный механизм: при раскладке `4MB (FS:2MB)` сектор EEPROM (1019, -смещение `0x3FB000`) попадает в узкую щель между концом ФС (`0x3FA000`) и -служебной областью SDK (`0x3FC000`) и на этой связке платы и SDK оказывается -защищён от стирания. Доказать это не удалось, поэтому формулировка гипотетическая. -Переход на LittleFS выбран не как обход симптома, а как переезд на механизм, -работоспособность которого на этом железе подтверждена. +| Область | Смещение при настройке 4MB | Внутри чипа 2 МБ (`0x200000`)? | +|---|---|---| +| Сектор EEPROM | `0x3FB000` = 4 173 824 | нет, вдвое дальше конца | +| Начало LittleFS | `0x400000` = 2 097 152 | нет, ровно за границей | -Практическое следствие: **в IDE обязателен вариант `Flash Size` с ненулевой -файловой системой**. При `FS:none` сохранять настройки будет некуда, и прошивка -скажет об этом в лог при старте. +`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 Отказоустойчивость 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 index 8b7a68a..7660449 100644 --- 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 @@ -36,8 +36,11 @@ - ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient / LittleFS (часть ESP8266 core) - В IDE обязателен вариант Flash Size с ненулевой файловой системой, - например «4MB (FS:2MB OTA:~1019KB)» — иначе настройки сохранять некуда. + ВАЖНО про Flash Size в IDE. Целевая плата имеет 2 МБ флеша, ставить нужно + «2MB (FS:128KB OTA:~960KB)». Выбранный размер обязан совпадать с физическим: + при настройке 4MB на чипе 2MB области EEPROM и файловой системы уходят за + границу микросхемы, и настройки молча не сохраняются. Прошивка печатает + фактический размер в лог при старте строкой [FLASH]. ============================================================ */ From 703c4d46c6296211d1d0ffd77152bbe389af368b Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 20:04:42 +0700 Subject: [PATCH 09/12] =?UTF-8?q?=D0=97=D0=B0=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=83=D1=8E=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B6=D0=B5=D0=BB=D0=B5=D0=B7=D0=B5=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83=202MB=20(FS:64KB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прошивка запущена на целевой плате: LittleFS монтируется и отдаёт 45056 Б, файл настроек читается при старте. В документации указан фактически использованный вариант Flash Size вместо предполагавшегося FS:128KB. Co-Authored-By: Claude Opus 5 --- pump_controller_8_2_OLED_DONE/MANUAL.md | 2 +- pump_controller_8_2_OLED_DONE/README.md | 5 ++++- .../pump_controller_8_2_OLED_DONE.ino | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pump_controller_8_2_OLED_DONE/MANUAL.md b/pump_controller_8_2_OLED_DONE/MANUAL.md index 4b45c05..82701b7 100644 --- a/pump_controller_8_2_OLED_DONE/MANUAL.md +++ b/pump_controller_8_2_OLED_DONE/MANUAL.md @@ -175,7 +175,7 @@ STATUS: OK > Если панель показывает красное предупреждение «Файловая система недоступна» > или настройки пропадают после перезагрузки — причина в настройке `Flash Size` > при прошивке. Она обязана совпадать с фактическим размером памяти платы: -> для этих приборов **`2MB (FS:128KB OTA:~960KB)`**. Вариант `4MB` на плате с +> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с > 2 МБ приводит ровно к такому симптому. > Русские буквы и пробелы в имени устройства **не сохранятся** — они будут diff --git a/pump_controller_8_2_OLED_DONE/README.md b/pump_controller_8_2_OLED_DONE/README.md index f82b037..e586ef0 100644 --- a/pump_controller_8_2_OLED_DONE/README.md +++ b/pump_controller_8_2_OLED_DONE/README.md @@ -120,7 +120,10 @@ SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (констан Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600. -**Flash Size: `2MB (FS:128KB OTA:~960KB)`** — под физические 2 МБ целевой платы. +**Flash Size: `2MB (FS:64KB OTA:~992KB)`** — под физические 2 МБ целевой платы. +Именно этот вариант проверен на железе: LittleFS отдаёт 45 056 байт полезного +объёма, чего для `settings.json` (несколько сотен байт) с запасом. Подойдёт и +любой другой `2MB (FS:...)`, если понадобится больше места под файлы. Два требования к этой настройке, оба обязательные: 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 index 7660449..c1b34e0 100644 --- 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 @@ -37,7 +37,7 @@ (часть ESP8266 core) ВАЖНО про Flash Size в IDE. Целевая плата имеет 2 МБ флеша, ставить нужно - «2MB (FS:128KB OTA:~960KB)». Выбранный размер обязан совпадать с физическим: + «2MB (FS:64KB OTA:~992KB)». Выбранный размер обязан совпадать с физическим: при настройке 4MB на чипе 2MB области EEPROM и файловой системы уходят за границу микросхемы, и настройки молча не сохраняются. Прошивка печатает фактический размер в лог при старте строкой [FLASH]. From 3f21b44a65b4d14a230ea1474bec09ce68838b75 Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 20:05:42 +0700 Subject: [PATCH 10/12] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=20=D0=BA=D0=BE=D0=B4=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=87=D0=B8=D0=BD=D1=83=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=20EEPROM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Комментарий утверждал, что запись в EEPROM на этой плате проваливается, что читается как «EEPROM сломана». На деле причина была в неверном Flash Size, и с верной настройкой работали бы оба механизма. Формулировка исправлена, чтобы следующий читатель не пошёл менять хранилище вместо проверки размера чипа. Co-Authored-By: Claude Opus 5 --- .../pump_controller_8_2_OLED_DONE.ino | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index c1b34e0..6f6ab32 100644 --- 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 @@ -80,9 +80,12 @@ const int WEBHOOK_MAX_LEN = 220; const int DEVICE_MAX_LEN = 31; // --- ХРАНИЛИЩЕ НАСТРОЕК --- -// LittleFS, а не EEPROM: запись в сектор EEPROM на этой плате проваливается -// (commit() возвращает false), тогда как файловая система на том же чипе -// работает. Подробности — в README, раздел «Хранение настроек». +// LittleFS, а не EEPROM. Важно не перепутать причины: EEPROM переставала +// сохранять из-за неверного Flash Size в IDE (см. шапку файла), а не потому, +// что механизм плох — с верной настройкой работали бы оба. LittleFS выбран +// за то, что новое поле добавляется одной строкой, без арифметики адресов и +// версионирования раскладки, а старый файл не ломается при этом. +// Разбор случая — в README, раздел 6.1. const char* SETTINGS_PATH = "/settings.json"; // Имена ключей вынесены в константы намеренно: рассинхронизация имён между From 2aa7e20fd4efaba32401204516e74c31f3a89229 Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 22:56:50 +0700 Subject: [PATCH 11/12] docs: add pump_controller_8_2_OLED_DONE to the projects index Co-Authored-By: Claude Opus 5 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) 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). From 42cbb82492f918cdbeb453935b15dc7ebd3357a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9E=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D0=B5=D0=B2?= Date: Fri, 7 Aug 2026 23:13:58 +0700 Subject: [PATCH 12/12] =?UTF-8?q?chore:=20allowlist=20.gitignore,=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9=20=D0=BA=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=20D:\Soft\Arduino=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D1=8C=20=D1=80=D0=B5=D0=BF=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Локально все скетчи лежат вперемешку в одной папке: рабочие проекты, старые версии, эксперименты, архивы и драйверы. Чтобы этот каталог мог служить рабочим деревом репозитория, .gitignore инвертирован — по умолчанию игнорируется всё, проекты добавляются точечно строкой "!/ИмяПапки/". Прежние правила (артефакты сборки, мусор ОС, secrets.h) сохранены и перенесены ниже белого списка: более поздний шаблон побеждает, поэтому они продолжают действовать внутри отслеживаемых папок. Co-Authored-By: Claude Opus 5 --- .gitignore | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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