Перенести настройки из EEPROM в LittleFS

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

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

Настройки теперь в /settings.json (ArduinoJson v7). Загрузка
отказоустойчива: отсутствующий или битый ключ оставляет значение по
умолчанию, поэтому файл от прошлой версии прошивки не ломается при
добавлении полей. Имена ключей вынесены в константы KEY_*, используемые и
при чтении, и при записи.

Молчаливой потери настроек больше нет: при недоступной ФС взводится
settingsFsReady = false, и веб-панель показывает предупреждение, что
настройки не переживут перезагрузку. В лог при старте выводится геометрия
ФС и предупреждение при выборе FS:none.

Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра
3.1.2 — предупреждений нет. На железе не проверялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:41:05 +07:00
co-authored by Claude Opus 5
parent ca02a48684
commit 4c0f9672f3
3 changed files with 277 additions and 188 deletions
+6 -2
View File
@@ -169,8 +169,12 @@ STATUS: OK
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` | | **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) | | **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
Настройки сохраняются в памяти прибора, переживают отключение питания и сброс Настройки сохраняются в файл во внутренней памяти прибора, переживают
Wi-Fi. отключение питания и сброс Wi-Fi.
> Если панель показывает красное предупреждение «Файловая система недоступна»,
> настройки работают только до перезагрузки. Причина почти всегда в настройке
> `Flash Size` при прошивке — нужен вариант с файловой системой, не `FS:none`.
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут > Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки. > отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
+94 -34
View File
@@ -105,11 +105,16 @@ SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (констан
* Adafruit SSD1306 * Adafruit SSD1306
* Adafruit GFX Library * Adafruit GFX Library
* WiFiManager (tzapu) * WiFiManager (tzapu)
* ArduinoJson (v7)
Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`, Входят в 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 ## 5. Webhook
`POST` на `webhook_url`, `Content-Type: application/json`: `POST` на `settings.webhookUrl`, `Content-Type: application/json`:
```json ```json
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"} {"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
@@ -191,7 +196,7 @@ pE = стоит -> реле «Резерв» выключено
| `MAIN PUMPS` | `ALARM` / `OK` | | `MAIN PUMPS` | `ALARM` / `OK` |
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` | | `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
Поле `device` берётся из настройки `device_name` (задаётся через веб-панель, Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело `[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
запроса ему нечем. запроса ему нечем.
@@ -241,44 +246,99 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о
--- ---
## 6. Хранение настроек (EEPROM) ## 6. Хранение настроек (LittleFS)
Эмулируемая EEPROM, 512 байт. Занято 258. Настройки лежат в файловой системе LittleFS, в файле `/settings.json`:
| Адрес | Размер | Содержимое | По умолчанию | ```json
|---|---|---|---| {"webhook":"https://...","device":"Wemos_D1_Pump","alarm_delay":5,"emerg_delay":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` |
Значения по умолчанию воспроизводят поведение прошивки до появления настроек. | Поле структуры `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 | Действие | ### 6.1 Почему не EEPROM
|---|---|
| `0xA55B` | читаются все поля |
| `0xA55A` | **миграция v1 → v2**: `webhook_url` сохраняется, новые поля получают значения по умолчанию, magic перезаписывается |
| иное | первый старт, все поля по умолчанию |
Миграция нужна, чтобы уже прошитые приборы не потеряли настроенный webhook при Изначально настройки хранились в эмулируемой EEPROM. На целевой плате это **не
обновлении прошивки. работает**: `EEPROM.commit()` возвращает `false`, то есть падает сам вызов SDK
(`spi_flash_erase_sector` либо `spi_flash_write`), и настройки молча теряются.
При чтении v2 значения дополнительно проверяются: пустое `device_name` и Проверкой исходников исключены: ошибка в библиотеке EEPROM, перекрытие адресов,
выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию — иначе мусор стирание сектора со стороны `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()` сразу выходит и пишет в лог `sendPostWebhook()` сразу выходит и пишет в лог
`[HTTP] Webhook URL не задан — отправка пропущена`. `[HTTP] Webhook URL не задан — отправка пропущена`.
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM,
поэтому сброс Wi-Fi настройки из этой таблицы не затрагивает.
--- ---
## 7. Веб-интерфейс ## 7. Веб-интерфейс
@@ -295,7 +355,7 @@ Magic одновременно служит номером версии раск
### 7.1 `/set_config` ### 7.1 `/set_config`
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** Одна форма на все настройки. **Пустое или неверное поле означает «не менять»**
так частично заполненная форма не обнуляет остальные параметры. Запись в EEPROM так частично заполненная форма не обнуляет остальные параметры. Запись в файл
выполняется только если что-то реально изменилось. выполняется только если что-то реально изменилось.
| Параметр | Валидация | При отказе | | Параметр | Валидация | При отказе |
@@ -403,9 +463,9 @@ STATUS: OK
портал придётся своим таймером. портал придётся своим таймером.
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией * Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
не считается и никак не сигнализируется. не считается и никак не сигнализируется.
* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем, * Webhook URL хранится в `/settings.json` в открытом виде и отдаётся
кто может её открыть. Если URL содержит секретный токен — доступ к панели веб-панелью всем, кто может её открыть. Если URL содержит секретный токен —
равносилен доступу к токену. доступ к панели равносилен доступу к токену.
--- ---
@@ -26,11 +26,18 @@
При необходимости решается на монтаже — нагрузка снимается с нормально При необходимости решается на монтаже — нагрузка снимается с нормально
замкнутого (NC) контакта реле. замкнутого (NC) контакта реле.
Настройки хранятся в LittleFS, файл /settings.json.
Библиотеки (установить через Library Manager): Библиотеки (установить через Library Manager):
- Adafruit SSD1306 - Adafruit SSD1306
- Adafruit GFX Library - Adafruit GFX Library
- WiFiManager (tzapu) - 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 <ESP8266HTTPClient.h> #include <ESP8266HTTPClient.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
#include <WiFiManager.h> #include <WiFiManager.h>
#include <EEPROM.h> #include <LittleFS.h>
#include <ArduinoJson.h>
#include <Wire.h> #include <Wire.h>
#include <flash_hal.h> // EEPROM_start / FS_PHYS_ADDR — только для диагностики #include <flash_hal.h> // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики
#include <Adafruit_GFX.h> #include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h> #include <Adafruit_SSD1306.h>
@@ -57,31 +65,42 @@ bool oledOK = false;
// --- КОНФИГУРАЦИЯ --- // --- КОНФИГУРАЦИЯ ---
const char* config_ap_name = "Pump_Control_Set"; const char* config_ap_name = "Pump_Control_Set";
// Адрес webhook задаётся через веб-панель (/set_config) и хранится в EEPROM. // --- ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ ---
// В исходнике намеренно пусто: URL содержит секретный токен. // Webhook намеренно пуст: URL содержит секретный токен, ему не место в исходнике.
const char* WEBHOOK_URL_DEFAULT = ""; const char* WEBHOOK_URL_DEFAULT = "";
const char* DEVICE_NAME_DEFAULT = "Wemos_D1_Pump"; const char* DEVICE_NAME_DEFAULT = "Wemos_D1_Pump";
const uint16_t ALARM_DELAY_DEFAULT_SEC = 5; // выдержка аварии основных насосов const uint16_t ALARM_DELAY_DEFAULT_SEC = 5; // выдержка аварии основных насосов
const uint16_t EMERG_DELAY_DEFAULT_SEC = 0; // выдержка пуска резерва (0 = сразу) const uint16_t EMERG_DELAY_DEFAULT_SEC = 0; // выдержка пуска резерва (0 = сразу)
const uint16_t DELAY_MAX_SEC = 3600; 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 WEBHOOK_MAX_LEN = 220;
const int EEPROM_DEVICE_ADDR = 222; const int DEVICE_MAX_LEN = 31;
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 device_name[DEVICE_MAX_LEN + 1]; // LittleFS, а не EEPROM: запись в сектор EEPROM на этой плате проваливается
uint16_t alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; // (commit() возвращает false), тогда как файловая система на том же чипе
uint16_t emergDelaySec = EMERG_DELAY_DEFAULT_SEC; // работает. Подробности — в 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; const int IN_PUMP_1 = D5;
@@ -135,120 +154,125 @@ unsigned long lastDisplayUpdate = 0;
const unsigned long DISPLAY_INTERVAL = 1000; const unsigned long DISPLAY_INTERVAL = 1000;
// ============================================================ // ============================================================
// EEPROM // НАСТРОЙКИ (LittleFS + JSON)
// ============================================================ // ============================================================
// Запись строки фиксированной длины с добивкой нулями.
static void eepromWriteStr(int addr, int fieldLen, const char* value) { void settingsDefaults() {
int len = (int)strlen(value); strlcpy(settings.webhookUrl, WEBHOOK_URL_DEFAULT, sizeof(settings.webhookUrl));
for (int i = 0; i < fieldLen; i++) { strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
EEPROM.write(addr + i, i < len ? value[i] : 0); 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); // уже проставленное settingsDefaults(). Благодаря этому файл, записанный
dst[fieldLen] = '\0'; // прошлой версией прошивки, не ломается при добавлении новых полей.
} static bool settingsLoad() {
File f = LittleFS.open(SETTINGS_PATH, "r");
// Возвращает true только если запись подтверждена перечитыванием из флеша. if (!f) {
// Проверка нужна потому, что EEPROM.commit() умеет вернуть false (например при Serial.println(F("[SET] Файл настроек не найден"));
// неверно выбранном размере флеша в 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, настройки НЕ сохранены"));
return false; return false;
} }
// Повторный begin() перечитывает сектор из флеша в буфер — это и есть проверка, JsonDocument doc;
// что записанное действительно легло на флеш, а не только в ОЗУ. DeserializationError err = deserializeJson(doc, f);
EEPROM.begin(EEPROM_SIZE); f.close();
uint16_t magicChk, alarmChk, emergChk; if (err) {
char urlChk[WEBHOOK_MAX_LEN + 1]; Serial.printf("[SET] ОШИБКА разбора JSON: %s\n", err.c_str());
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; return false;
} }
Serial.printf("[EEPROM] Сохранено и проверено: device=%s, авария=%u с, резерв=%u с\n", const char* v;
device_name, alarmDelaySec, emergDelaySec); v = doc[KEY_WEBHOOK];
Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); 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; return true;
} }
static void configSetDefaults() { // Перезаписывает файл целиком. true только если запись реально удалась.
strncpy(webhook_url, WEBHOOK_URL_DEFAULT, WEBHOOK_MAX_LEN); bool settingsSave() {
webhook_url[WEBHOOK_MAX_LEN] = '\0'; if (!settingsFsReady) {
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN); Serial.println(F("[SET] ОШИБКА: файловая система недоступна, сохранять некуда"));
device_name[DEVICE_MAX_LEN] = '\0'; return false;
alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; }
emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
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() { // Монтирует ФС и заполняет settings. Вызывать первым из всего, что работает
uint16_t magic; // с LittleFS: повторный LittleFS.begin() в других местах не нужен.
EEPROM.get(EEPROM_MAGIC_ADDR, magic); void settingsBegin() {
settingsDefaults();
if (magic == EEPROM_MAGIC_V2) { if (!LittleFS.begin()) {
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); Serial.println(F("[FS] Не смонтирована, форматирую..."));
eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name); if (!LittleFS.format() || !LittleFS.begin()) {
EEPROM.get(EEPROM_ALARM_ADDR, alarmDelaySec); Serial.println(F("[FS] ОШИБКА: файловая система недоступна."));
EEPROM.get(EEPROM_EMERG_ADDR, emergDelaySec); Serial.println(F("[FS] Прибор работает, но настройки не переживут перезагрузку."));
settingsFsReady = false;
// Защита от мусора во флеше: подставляем значения по умолчанию, а не return;
// уходим в заведомо неверную выдержку.
if (device_name[0] == '\0') {
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
device_name[DEVICE_MAX_LEN] = '\0';
} }
if (alarmDelaySec > DELAY_MAX_SEC) alarmDelaySec = ALARM_DELAY_DEFAULT_SEC; Serial.println(F("[FS] Отформатирована успешно"));
if (emergDelaySec > DELAY_MAX_SEC) emergDelaySec = EMERG_DELAY_DEFAULT_SEC; }
settingsFsReady = true;
Serial.printf("[EEPROM] Загружено: device=%s, авария=%u с, резерв=%u с\n", FSInfo info;
device_name, alarmDelaySec, emergDelaySec); if (LittleFS.info(info)) {
Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)")); Serial.printf("[FS] Смонтирована: всего %u Б, занято %u Б\n",
(unsigned)info.totalBytes, (unsigned)info.usedBytes);
}
} else if (magic == EEPROM_MAGIC_V1) { if (!settingsLoad()) {
// Миграция v1 -> v2: URL сохраняем, новые поля получают значения по умолчанию. Serial.println(F("[SET] Создаю файл со значениями по умолчанию"));
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url); settingsSave();
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] Первый старт, записаны значения по умолчанию."));
} }
} }
@@ -383,7 +407,7 @@ static bool parseWebhookHost(const char* url, String &host) {
} }
void sendPostWebhook(String eventName, String status) { void sendPostWebhook(String eventName, String status) {
if (webhook_url[0] == '\0') { if (settings.webhookUrl[0] == '\0') {
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена")); Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
return; return;
} }
@@ -395,8 +419,8 @@ void sendPostWebhook(String eventName, String status) {
const unsigned long started = millis(); const unsigned long started = millis();
String host; String host;
if (!parseWebhookHost(webhook_url, host)) { if (!parseWebhookHost(settings.webhookUrl, host)) {
Serial.println("[HTTP] Не удалось разобрать URL: " + String(webhook_url)); Serial.println("[HTTP] Не удалось разобрать URL: " + String(settings.webhookUrl));
return; return;
} }
@@ -431,18 +455,18 @@ void sendPostWebhook(String eventName, String status) {
http.setReuse(false); // не держать TLS-сокет после отправки http.setReuse(false); // не держать TLS-сокет после отправки
Serial.print(F("[HTTP] POST: ")); 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() не удался")); Serial.println(F("[HTTP] begin() не удался"));
return; return;
} }
http.addHeader("Content-Type", "application/json"); http.addHeader("Content-Type", "application/json");
// device_name отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование // settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
// в JSON не требуется — сломать тело запроса ему нечем. // в JSON не требуется — сломать тело запроса ему нечем.
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status + String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status +
"\",\"device\":\"" + String(device_name) + "\"}"; "\",\"device\":\"" + String(settings.deviceName) + "\"}";
int code = http.POST(body); int code = http.POST(body);
if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started); if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started);
@@ -642,20 +666,24 @@ String getHTML() {
// --- Настройки --- // --- Настройки ---
html += F("<div class='cfg-row'><div class='cfg-panel'><div class='clabel'>Настройки</div>"); html += F("<div class='cfg-row'><div class='cfg-panel'><div class='clabel'>Настройки</div>");
if (!settingsFsReady) {
html += F("<div class='smsg crit' style='margin-bottom:8px'>"
"Файловая система недоступна — настройки не переживут перезагрузку!</div>");
}
html += "<div class='wh-url'>Webhook: " + html += "<div class='wh-url'>Webhook: " +
String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "</div>"; String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "</div>";
html += F("<form action='/set_config' method='POST' style='margin:0'><div class='cfg-grid'>"); html += F("<form action='/set_config' method='POST' style='margin:0'><div class='cfg-grid'>");
html += F("<div class='cfg-f cfg-wide'><div class='cfg-l'>Webhook URL</div>" html += F("<div class='cfg-f cfg-wide'><div class='cfg-l'>Webhook URL</div>"
"<input class='wh-input' type='text' name='url' placeholder='https://... (пусто = не менять)' maxlength='220'></div>"); "<input class='wh-input' type='text' name='url' placeholder='https://... (пусто = не менять)' maxlength='220'></div>");
html += "<div class='cfg-f cfg-wide'><div class='cfg-l'>Имя устройства (A-Z a-z 0-9 _ -)</div>" html += "<div class='cfg-f cfg-wide'><div class='cfg-l'>Имя устройства (A-Z a-z 0-9 _ -)</div>"
"<input class='wh-input' type='text' name='device' value='" + String(device_name) + "<input class='wh-input' type='text' name='device' value='" + String(settings.deviceName) +
"' maxlength='31'></div>"; "' maxlength='31'></div>";
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка аварии, с</div>" html += "<div class='cfg-f'><div class='cfg-l'>Выдержка аварии, с</div>"
"<input class='wh-input' type='number' name='alarm_delay' min='0' max='3600' value='" + "<input class='wh-input' type='number' name='alarm_delay' min='0' max='3600' value='" +
String(alarmDelaySec) + "'></div>"; String(settings.alarmDelaySec) + "'></div>";
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка резерва, с</div>" html += "<div class='cfg-f'><div class='cfg-l'>Выдержка резерва, с</div>"
"<input class='wh-input' type='number' name='emerg_delay' min='0' max='3600' value='" + "<input class='wh-input' type='number' name='emerg_delay' min='0' max='3600' value='" +
String(emergDelaySec) + "'></div>"; String(settings.emergDelaySec) + "'></div>";
html += F("</div><button type='submit' class='cbtn-save' style='margin-top:8px'>Сохранить</button>" html += F("</div><button type='submit' class='cbtn-save' style='margin-top:8px'>Сохранить</button>"
"</form></div></div>"); "</form></div></div>");
@@ -723,14 +751,8 @@ void setup() {
Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n", Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n",
ESP.getSketchSize(), ESP.getFreeSketchSpace()); ESP.getSketchSize(), ESP.getFreeSketchSpace());
// Куда именно EEPROM собирается писать. Если это смещение выходит за размер // Где лежит файловая система. Если область выходит за размер микросхемы,
// физической микросхемы, spi_flash_erase_sector() провалится и commit() // LittleFS не смонтируется и настройки сохранять будет некуда.
// вернёт 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", Serial.printf("[FLASH] файловая система: смещение 0x%06X, размер %u Б%s\n",
FS_PHYS_ADDR, FS_PHYS_SIZE, FS_PHYS_ADDR, FS_PHYS_SIZE,
FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal
@@ -738,12 +760,15 @@ void setup() {
if (flashIde != flashReal) { if (flashIde != flashReal) {
Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует")); 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); settingsBegin();
eepromReadConfig();
pinMode(p1.pin, INPUT_PULLUP); pinMode(p1.pin, INPUT_PULLUP);
pinMode(p2.pin, INPUT_PULLUP); pinMode(p2.pin, INPUT_PULLUP);
@@ -788,34 +813,34 @@ void setup() {
if (server.hasArg("url")) { if (server.hasArg("url")) {
String v = server.arg("url"); v.trim(); String v = server.arg("url"); v.trim();
if (v.length() > 0 && v.length() <= WEBHOOK_MAX_LEN) { 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; changed = true;
} }
} }
if (server.hasArg("device")) { if (server.hasArg("device")) {
String v = sanitizeDeviceName(server.arg("device")); String v = sanitizeDeviceName(server.arg("device"));
if (v.length() > 0 && v != String(device_name)) { if (v.length() > 0 && v != String(settings.deviceName)) {
v.toCharArray(device_name, DEVICE_MAX_LEN + 1); v.toCharArray(settings.deviceName, DEVICE_MAX_LEN + 1);
changed = true; changed = true;
} }
} }
if (server.hasArg("alarm_delay")) { if (server.hasArg("alarm_delay")) {
uint16_t v; uint16_t v;
if (parseDelaySec(server.arg("alarm_delay"), v) && v != alarmDelaySec) { if (parseDelaySec(server.arg("alarm_delay"), v) && v != settings.alarmDelaySec) {
alarmDelaySec = v; settings.alarmDelaySec = v;
changed = true; changed = true;
} }
} }
if (server.hasArg("emerg_delay")) { if (server.hasArg("emerg_delay")) {
uint16_t v; uint16_t v;
if (parseDelaySec(server.arg("emerg_delay"), v) && v != emergDelaySec) { if (parseDelaySec(server.arg("emerg_delay"), v) && v != settings.emergDelaySec) {
emergDelaySec = v; settings.emergDelaySec = v;
changed = true; changed = true;
} }
} }
if (changed) eepromWriteConfig(); if (changed) settingsSave();
else Serial.println(F("[CONFIG] Изменений нет, запись в EEPROM пропущена")); else Serial.println(F("[SET] Изменений нет, запись пропущена"));
server.sendHeader("Location", "/"); server.sendHeader("Location", "/");
server.send(303); server.send(303);
@@ -891,14 +916,14 @@ void loop() {
} }
// --- Основная авария --- // --- Основная авария ---
// Выдержка одна на реле и вебхук: при alarmDelaySec = 0 условие выполняется // Выдержка одна на реле и вебхук: при settings.alarmDelaySec = 0 условие выполняется
// на следующей же итерации, то есть реакция мгновенная. // на следующей же итерации, то есть реакция мгновенная.
bool bothStopped = (!p1.stableState && !p2.stableState); bool bothStopped = (!p1.stableState && !p2.stableState);
if (bothStopped) { if (bothStopped) {
if (!mainAlarmPending) { if (!mainAlarmPending) {
mainAlarmPending = true; mainAlarmPending = true;
mainAlarmStartTime = millis(); mainAlarmStartTime = millis();
} else if (millis() - mainAlarmStartTime >= (unsigned long)alarmDelaySec * 1000UL) { } else if (millis() - mainAlarmStartTime >= (unsigned long)settings.alarmDelaySec * 1000UL) {
setRelay(OUT_ALARM_MAIN, true); setRelay(OUT_ALARM_MAIN, true);
if (!mainAlarmSent) { if (!mainAlarmSent) {
Serial.println(F("[ALERT] Авария основных насосов")); Serial.println(F("[ALERT] Авария основных насосов"));
@@ -921,7 +946,7 @@ void loop() {
if (!emergPending) { if (!emergPending) {
emergPending = true; emergPending = true;
emergStartTime = millis(); emergStartTime = millis();
} else if (millis() - emergStartTime >= (unsigned long)emergDelaySec * 1000UL) { } else if (millis() - emergStartTime >= (unsigned long)settings.emergDelaySec * 1000UL) {
setRelay(OUT_ALARM_EMERGENCY, true); setRelay(OUT_ALARM_EMERGENCY, true);
if (!emergencyActiveSent) { if (!emergencyActiveSent) {
Serial.println(F("[WARN] Запущен экстренный насос")); Serial.println(F("[WARN] Запущен экстренный насос"));