Термостат на веранде отключили светом на минуту — и вместе со светом пропали уставки. Жил он на константах в скетче, и каждое изменение порога значило: кабель, ноутбук, прошивка, лестница на чердак. EEPROM байтовый и мелкий: порог туда влез бы, а расписание нагрева на неделю с именами узлов — уже нет. Конфиг, который правится блокнотом и переживает выключение, хотел жить в файле, а карты в устройстве не было и не предвиделось.
Ответ лежал в самом чипе: ESP32 несёт на плате флеш, и хвост отдаётся файловой системе LittleFS. Тот же путь «файл → строка → документ», что и для SD-карты в уроке про конфиг на SD, только SPI-магистраль и слот не нужны — мегабайты флеша уже внутри. Разбор строки тот же, что для любого конфига: урок про конфиг из JSON показал дефолты для отсутствующих полей, здесь они пригодятся сразу. Отличие начинается не при чтении, а при записи: у флеша чипа свои правила выживания при отключении питания, и они лучше продуманы, чем у карты.
План: чтение конфига при старте с LittleFS.begin(true) для первого запуска, сохранение по событию и главная грабля файловых записей — усечение при снятии питания посреди записи, с лечением «временный файл и rename». В конце — сравнение с SD по скорости, износу и съёмности, чтобы выбор места хранения опирался на числа, а не на веру.
Читаем конфиг при старте
Первый запуск свежепрошитой платы — отдельный случай: раздел флеша ещё не размечен, и монтирование вернёт false. LittleFS.begin(true) с true в аргументе форматирует раздел при неудачном монтировании — плата стартует с пустой, но живой файловой системой, и дальше ветка с дефолтами делает своё дело. Форматирование стоит секунду-другую, случается один раз за жизнь платы, и я ставлю true всегда: скетч, который не стартует без ручной разметки раздела, на объекте становится сюрпризом. У true есть и обратная сторона: если раздел повреждён уже после наладки — например, перегретым чипом или глючным питанием, — форматирование молча сотрёт всё содержимое, и скетч останется один на один с дефолтами; для конфига это штатная ситуация, для накопленных логов — потеря, о которой стоит знать заранее.
Дальше знакомый конвейер: LittleFS.open(FAJL, "r"), readString() вычитывает файл целиком, deserializeJson разбирает строку, поля выходят по имени с дефолтом через оператор |. Библиотеке всё равно, откуда пришла строка — с карты, из флеша или из монитора: урок про диагностику ошибок разбора работает здесь же, err.c_str() называет причину по имени. Конфиг в пару сотен байт вычитывается мгновенно, и файл живёт открытым долю секунды.
Валидация входа после разбора — та же, что для любого конфига: урок про валидацию JSON даёт полный набор, я беру минимум — constrain по числовым полям. Холостой прогон с файлом из пары скобок обязателен: устройство должно жить на дефолтах, а не лежать. Соседняя альтернатива на том же чипе — урок про Preferences: ключ-значение без JSON, удобно для десятка скаляров, тесно для расписаний и списков.
#include <ArduinoJson.h>#include <LittleFS.h> const char* FAJL = "/config.json";const char* TMP = "/config.tmp"; JsonDocument cfg; // v6: DynamicJsonDocument(256) cfg; float tmin = 18.5; // дефолты: работают, пока файл не прочитанfloat hys = 0.5;String node = "terrasa"; void chitatKonfig() { if (!LittleFS.exists(FAJL)) { Serial.println("config.json net: rabotayu na defoltah"); return; } File f = LittleFS.open(FAJL, "r"); if (!f) { Serial.println("open vernul false"); return; } String tekst = f.readString(); // файл целиком в строку f.close(); // файл нужен долю секунды DeserializationError err = deserializeJson(cfg, tekst); if (err) { Serial.print("config ne razobralsya: "); Serial.println(err.c_str()); // сюда попадут и остатки сбитой записи return; // дефолты остаются в силе } tmin = cfg["tmin"] | tmin; hys = cfg["hys"] | hys; node = String(cfg["node"] | "terrasa"); Serial.print("config primen: node="); Serial.print(node); Serial.print(" tmin="); Serial.println(tmin, 1);} void sohranitKonfig(float novyjTmin) { tmin = novyjTmin; cfg.clear(); cfg["tmin"] = tmin; cfg["hys"] = hys; cfg["node"] = node; File f = LittleFS.open(TMP, "w"); // пишем во временный if (!f) { Serial.println("tmp ne otkrylsya"); return; } serializeJson(cfg, f); f.close(); // документ целиком во флеше LittleFS.rename(TMP, FAJL); // rename у LittleFS атомарен Serial.println("konfig sohranen: tmp -> config.json");} void setup() { Serial.begin(115200); if (!LittleFS.begin(true)) { // true — формат при первом старте Serial.println("LittleFS ne podnyalsya"); while (true) delay(1000); } Serial.printf("zanyato %u iz %u bayt\n", LittleFS.usedBytes(), LittleFS.totalBytes()); chitatKonfig();} void loop() { if (Serial.available()) { String cmd = Serial.readStringUntil(10); if (cmd.startsWith("set ")) { sohranitKonfig(cmd.substring(4).toFloat()); } }}Сохранение — единственное место, где конфиг в опасности, и здесь стоит вся защита. Запись на месте — open("/config.json", "w") — усекает файл первой же строкой: с этого мгновения конфига не существует, и питание, снятое до закрытия файла, оставит плате пустышку или обрезок. Временный файл разводит риск в стороны: погибает /config.tmp, а /config.json цел, и rename ставит точку одной операцией. У LittleFS rename атомарен даже при потере питания — на случайные отключения заточен сам проект littlefs, что подтверждает и его документация.
Ресурс записи — вторая причина не писать без нужды. Сектор NOR-флеша держит порядка десяти-ста тысяч циклов стирания: конфиг, сохраняемый по таймеру раз в минуту, угробит сектор за считанные недели, а сохраняемый по событию — смене уставки, — проживёт годы. То же правило действует и для SD-карты: урок про скорость записи и потери прогоняет его числами. Мои триггера сохранения — команда set из монитора и кнопка на месте; между щелчком и записью я держу паузу в пару секунд: человек докручивает, скетч пишет один раз — итоговую уставку, а не все промежуточные. Таймеров у конфига нет.
err после чтения — не повод паниковать, а штатная ветка: обрезок файла после сбоя приезжает в deserializeJson и честно разваливается с InvalidInput или EmptyInput, дефолты вступают в силу, плата работает. Устройство обязано пережить и пустой раздел, и мусор в конфиге — иначе первый же скачок света кладёт его до визита с ноутбуком.
LittleFS против SD
SD выигрывает в скорости и съёмности: SPI-карта читает и пишет в разы быстрее внутреннего флеша, карточку можно вынуть и править конфиг блокнотом на компьютере. Плата за это — пины SPI, слот, отдельное питание и капризная проводка на макетке. LittleFS живёт в чипе: пины свободны, провода не нужны, карта не потеряется и не отсыреет в щитке, но правка требует перезаливки файла через OTA или веб-интерфейс. Логи и большие файлы — работа для SD, о чём у меня разобрано в материале про логирование на SD; конфиг и паспорт устройства честнее держать во флеше того же чипа.
Конфиг — не единственный жилец раздела: туда же просятся страницы веб-интерфейса, сертификаты, кэш шрифтов для экрана. Бюджет стоит продумать до прошивки: под файловую систему отдаётся хвост флеша, и растянуть её потом без переразметки нельзя — переразметка стирает всё, включая свежесохранённые уставки. Я оставляю под LittleFS минимум полтора мегабайта и не держу в ней то, что можно перечитать из сети: конфиг — это то, что обязано пережить отсутствие сети.
Износ у обоих носителей конечен и управляется одним правилом: писать по событию, а не по таймеру. У SD износом управляет контроллер карточки со своим перемешиванием ячеек, у LittleFS — wear leveling самой файловой системы; на десятках тысяч циклов обе истории живут годами. Разница в сценарии отказа: у SD сбой питания бьёт по контроллеру карточки непредсказуемо, у LittleFS атомарный rename даёт железную гарантию на замену файла — и это главный аргумент в его пользу для уставок.
| Место хранения | Потеря питания посреди записи | Правка без платы |
|---|---|---|
| Константы в скетче | нечего терять: прежние значения и так в прошивке | нет: правка = прошивка |
| EEPROM | записанное перед сбоем может лечь вперемешку | да, командой из Serial |
| LittleFS, запись на месте | open с w усекает файл до нуля ещё до записи: обрезок или пустышка | да, файлом через OTA или веб |
| LittleFS, tmp + rename | погибает только tmp: старый конфиг цел | да, файлом через OTA или веб |
Файловой системы движок не эмулирует, поэтому файл — это String: cfgFajl держит содержимое /config.json, tmpFajl — /config.tmp, rename заменяется присваиванием. Питание играет тумблер D3: сняли во время окна записи — увидите ровно то, что случилось бы на плате. В штатном режиме tmp + rename погибнет временный файл, а конфиг уцелеет; команда naive в мониторе включает запись на месте — тот же скачок оставит пустышку, и после возврата питания устройство встанет на дефолты. Печать содержимого файла перед разбором — привычка из отладки по монитору: сначала глаза, потом deserializeJson.
Что попробовать. Ручка A0 — новая уставка, кнопка D2 — «сохранить»: светодиод мигает, пока идёт запись, потом rename. Снимите тумблер D3 в паузе — питание вернётся без последствий, файлы целы. Теперь команда naive, сохранение и снятие питания в середине окна: после включения скетч «перезагрузится», deserializeJson развалится на обрезке, и в мониторе встанут дефолты. Команда reboot повторяет старт в любой момент.
Модель rename честна по смыслу: cfgFajl меняется одной операцией в конце окна — как настоящий rename, который либо случился, либо нет, середины не бывает. На плате между закрытием tmp и rename существует то же самое окно, и в нём безопасно снимать питание: старый конфиг жив, новый не дописан. Именно поэтому временный файл — не перестраховка, а единственный способ дать отказу питания безвредный исход.
- 1. Что делает аргумент true в LittleFS.begin(true)?
- 2. Питание сняли посреди записи при схеме tmp + rename. Что осталось в /config.json?
- 3. Почему конфиг не сохраняют по таймеру раз в минуту?
- 4. Что SD-карта даёт против LittleFS для конфига?
- 5. deserializeJson вернул InvalidInput на файле после сбоя питания. Правильная реакция?
- LittleFS.begin(true) форматирует раздел при первом старте: плата стартует с пустой ФС и дефолтами, а не с отказом
- Путь данных тот же, что и для SD: open с r → readString → deserializeJson; err-ветка держит дефолты в силе
- Запись на месте усекает файл первой же строкой open с w; tmp + rename гибует только временный файл
- rename у LittleFS атомарен при потере питания; конфиг пишут по событию — ресурс сектора порядка 10-100 тысяч циклов
- Логи и большие файлы — SD; конфиг — LittleFS того же чипа: пины свободны, провода не нужны
Частые вопросы по теме урока
- Что делает аргумент true в LittleFS.begin(true)?
- Первый запуск встречает неразмеченный раздел; true форматирует его, и плата стартует с пустой файловой системой и дефолтами, а не с отказом. Форматирует раздел, если монтирование не удалось, — случай первой прошивки
- Питание сняли посреди записи при схеме tmp + rename. Что осталось в /config.json?
- Запись шла в /config.tmp, rename не случился; старый файл никто не трогал. У LittleFS rename атомарен даже при потере питания. Старый конфиг целиком: погиб только временный файл
- Почему конфиг не сохраняют по таймеру раз в минуту?
- Запись по событию — смене уставки — растягивает ресурс на годы; то же правило действует и для SD-карты. Сектор флеша держит порядка десяти-ста тысяч циклов стирания: сектор умрёт за недели
- Что SD-карта даёт против LittleFS для конфига?
- Карточку вынимаю и правлю на ноутбуке; скорость записи у SD тоже выше. Плата — пины SPI, слот и проводка. Съёмность: конфиг правится блокнотом на компьютере без платы
- deserializeJson вернул InvalidInput на файле после сбоя питания. Правильная реакция?
- Ветка ошибки держит прежние значения в силе; устройство обязано пережить и пустой раздел, и мусор в файле. Работать на дефолтах: обрезок эквивалентен отсутствующему файлу