From 4c0f9672f3be110f682065230c523bc2e6b5253f Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 7 Aug 2026 19:41:05 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=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] Запущен экстренный насос"));