Перенести настройки из 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:
Vitali
2026-08-07 19:41:05 +07:00
co-authored by Claude Opus 5
parent 56e6bfd098
commit b18f0d0ce0
3 changed files with 277 additions and 188 deletions
+94 -34
View File
@@ -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 содержит секретный токен —
доступ к панели равносилен доступу к токену.
---