Розеточный таймер полива в теплице: ESP32 держит WiFi, раз в пять минут меряет датчик влаги и щёлкает реле, когда показание уехало за порог. Порог я подобрал за неделю наблюдений — и на второй неделе сам же его стёр: перезалил скетч, а настройки жили в обычных переменных и ушли вместе с ним. В тот вечер завёл привычку: всё, что пользователь настраивал руками, кладу в Preferences — раздел флеша, который переживает и перепрошивку, и вилку из розетки.
Базу я уже разбирал: в уроке про Preferences и namespace описаны ключи с типом в имени и тихие дефолты чужим типом, а физику NVS с журналами записей подробно разложил раздел про EEPROM в своём уроке про переезд с EEPROM.h. Здесь — три вопроса, оставшихся за кадром: чем беззнаковые putUShort и getUShort удобны для порогов, где путаются во втором аргументе begin() и сколько ресурса у флеша, если писать настройки каждый день.
Слово про границы применимости, чтобы вы не тащили Preferences туда, где он не нужен. Каждая запись — это работа журнала и место в разделе, поэтому метрики, которые обновляются раз в минуту, я во флеш не пишу: им место в Serial, в HTTP-логе или в RTC-памяти, о которой отдельно рассказывал урок про счётчик снов. Preferences — про настройки, которые пользователь трогает руками или которые меняются раз в цикл работы: пороги, калибровки, имя сети, последний выбранный режим. Правило простое: если значение меняется чаще, чем вы дышите рядом с платой, хранилище ему нужно другое.
Беззнаковое семейство: порог на UShort
Preferences хранит числа типизированно: putUChar кладёт один байт (0-255), putUShort — два (0-65535), putUInt — четыре (до 4,3 млрд), а зеркальные get-вызовы принимают запасное значение прямо в аргументе: ключа нет — вернётся ваша заводская цифра. Для порога по 12-битному АЦП ESP32, который гуляет от 0 до 4095, UShort ровно в масть: два байта вместо четырёх, никаких минусов и никаких float. Яркость подсветки и флажки режимов живут в UChar, счётчики литров — в UInt, SSID и пароль WiFi — в строках через putString.
Одно правило я вынес из собственных промахов: тип живёт прямо в имени ключа. porog_u16 и ver_u8 говорят сами за себя — через год в чужом скетче не придётся угадывать, чем читать ключ. Второй якорь — ключ ver: на старте isKey("ver") подсказывает, что плата впервые загрузилась с пустым namespace, и пора залить заводские значения. И помните про лимит пятнадцати символов: он касается и имени namespace, и имени каждого ключа, причём строки чувствительны к регистру — Porog и porog уживутся, но один из них вы потом не найдёте.
Пара слов о механике вызовов, чтобы не обжигаться. putUShort возвращает size_t — размер записанного: двойка означает успех, ноль — отказ, и проверять его стоит сразу, а не после первого же «почему-то слетело». getUShort(key, def) требует запасное значение всегда, и это неплохая дисциплина: заводская цифра лежит прямо в месте чтения, без отдельной функции дефолтов. Смешивать типы в одном ключе я не советую и по другой причине: метка типа занимает место в журнале NVS, а чтение чужим типом вернёт дефолт молча, без единого предупреждения в мониторе.
| Что сохраняю | Вызов | Почему так |
|---|---|---|
| порог по АЦП, минуты, герцы | putUShort / getUShort("porog_u16", 2048) | два байта покрывают 0-65535 — 12-битному АЦП хватает с запасом |
| яркость, флажки режимов | putUChar / getUChar | один байт: не раздуваю журнал NVS лишними записями |
| SSID и пароль WiFi | putString / getString с буфером | SSID до 32 символов; лимит пятнадцати касается имени ключа, а не значения |
| первый запуск | isKey("ver") + putUShort("ver", 1) | ключа нет — заливаем заводские, есть — читаем свои |
#include <WiFi.h>#include <Preferences.h> Preferences cfg; // namespace с сетью и порогомconst int RELAY = 25; char ssid[33] = "HomeNet"; // запас: SSID до 32 символов + нольchar pass[65] = "12345678";int porog = 2048; // порог датчика, АЦП 12 бит: 0-4095 void setup() { Serial.begin(115200); pinMode(RELAY, OUTPUT); cfg.begin("app", false); // false = читать И писать (аргумент readonly) if (!cfg.isKey("ver")) { // пустой namespace: первый запуск cfg.putUShort("ver", 1); cfg.putString("ssid", ssid); cfg.putString("pass", pass); cfg.putUShort("porog_u16", porog); Serial.println("первый запуск: залил заводские настройки"); } else { cfg.getString("ssid", ssid, sizeof(ssid)); // строки читают в буфер cfg.getString("pass", pass, sizeof(pass)); porog = cfg.getUShort("porog_u16", 2048); // дефолт подстрахует Serial.print("порог из флеша: "); Serial.println(porog); }} void loop() { if (Serial.available() > 0) { int n = Serial.parseInt(); // прислали число — это новый порог if (n >= 100 && n <= 4000) { size_t zapisano = cfg.putUShort("porog_u16", n); if (zapisano == 0) { Serial.println("запись не прошла: ключ длиннее 15 символов,"); Serial.println("read-only или раздел забит"); } else { Serial.print("сохранил порог: "); Serial.println(n); } } } // далее — рабочая логика: измерение, WiFi.begin(ssid, pass), реле}Разбор по порядку. cfg.begin("app", false) открывает namespace app на чтение и запись: второй аргумент — флаг readonly, и false означает «читаю и пишу». Здесь я сам наступал на классические грабли: по памяти аргумент путается, и рука пишет begin("app", true) в надежде разрешить запись. На деле всё наоборот: true — только чтение, false — читать и писать. Путаница тянется от ESP8266, где EEPROM.begin(4096) принимал размер буфера, — привычка мерещится и на новом ядре.
Опасная сторона той же путаницы — тихие последствия. В read-only namespace putUShort честно вернёт ноль вместо двух записанных байт, и это единственный сигнал. А если namespace ещё не существует, begin("app", true) вернёт false: все get-вызовы начнут отдавать дефолты, и настройки выглядят стёртыми, хотя флеш вас не подводил — вы просто закрыли дверь раньше, чем положили в неё ключи. У меня потому железное правило: begin(true) — только для namespace, который заведомо заполнен при прошивке; для всего остального false, а защиту держу на возврате put-функций.
Пара практических мелочей поверх скетча. UShort в памяти лежит двумя байтами, старшим и младшим, и когда настройки едут в файл или на другую плату, значение режут на n / 256 и n % 256 — ровно так, как делает sim-блок ниже. Строки читаются в заранее заведённый буфер: getString требует размер, и запас под ноль-терминатор — не роскошь, а обязательство: SSID ровно 32 символа плюс ноль — это 33 байта. После чтения WiFi.begin(ssid, pass) принимает те же буферы напрямую, поэтому настройки применяются без промежуточных копий и лишних String.
Уборка ключей и ресурс флеша
Namespace пачкается незаметно: за полгода рефакторинга в app у меня скопились ключи трёх отброшенных версий — старый порог, забытая яркость, чьё-то imya_str. Места они тратят по-честному, а читаются разве что в отладчике. Уборка простая: remove("porog_old") вычищает один ключ, clear() вытирает весь открытый namespace целиком — удобно при смене формата конфигурации: стёрли, залили заводские, сохранили туда же пользовательские заново. И держите в голове, что открытым может быть один namespace за раз: end() закрывает текущий, после чего можно открыть следующий.
Про ресурс — без паники, но с цифрами. Сектор флеша ESP32 выдерживает порядка ста тысяч циклов стирания, и NVS внутри раздела распределяет износ по страницам: страница вытирается, когда заполняется её журнал. При типовом разделе в 20 КБ это означает: настройки, которые пишутся раз в сутки, выберут ресурс за сотни лет, а счётчик, инкрементируемый каждую секунду, — за год-два. Мой порог полива меняется руками раз в неделю, потому износ можно не считать; статистику работы я держу в отдельном namespace со сбросом раз в час, и даже такая частота укладывается в десятилетия — подробная арифметика износа лежит в уроке про ресурс EEPROM.
Отдельная песня — массовые записи, которых лучше избегать совсем. Если вам хочется писать показание датчика каждые пять минут, спросите себя, что вы будете делать со стопкой из двухсот тысяч записей в год: статистику собирают с приращениями за час, а не с сырыми точками. Так же и калибровки: коэффициент, который вы подбираете руками, пишется один раз по кнопке «сохранить», а не пересчитывается в каждом loop(). Каждая лишняя запись — это не только износ, но и лишняя строчка в журнале NVS, которую потом читает ваш же код при старте.
Движок симулятора Preferences не умеет, а EEPROM — умеет, и логика там та же: в sim-блоке число из Serial становится новым порогом и кладётся в память ровно как putUShort — двумя байтами, старшим и младшим. На реальной плате те же строчки с Preferences выглядят даже короче. Как строить само меню на Serial — с командами, подсказками и защитой от опечаток — разобрано в уроке про текстовое меню, а разбор числа из порта — в уроке про Serial.parseInt.
Сценарий проверки в симуляторе короткий. Запустите скетч: в Serial придёт строка о заводском пороге 512, потому что ячейки памяти ещё пусты. Отправьте в монитор число 750 — получите подтверждение сохранения; перезапустите симулятор и увидите, что порог теперь читается из памяти, а не из дефолта. Потом потяните ползунок потенциометра вверх: LED зажжётся, как только показание перевалит за ваш порог, — это реле полива в миниатюре.
- 1. Что означает второй аргумент в cfg.begin("app", true)?
- 2. Порог с 12-битного АЦП (0-4095) — каким вызовом сохранить?
- 3. getUShort("porog_u16", 2048) по ключу, которого в namespace нет, вернёт:
- 4. putUShort вернул 0. Что это скорее всего значит?
- 5. clear() и remove() — в чём разница?
- begin(name, readonly): false — читать и писать, true — только читать; путаницу аргумента тащат с ESP8266, где begin принимал размер буфера.
- putUShort/getUShort — два байта на 0-65535: порог 12-битного АЦП влезает целиком; UChar — 0-255, UInt — до 4,3 млрд.
- Имя namespace и ключей — до 15 символов, регистр значим; тип кладу в имя ключа (porog_u16), первый запуск ловлю через isKey("ver").
- remove() вычищает один ключ, clear() — весь открытый namespace; put, вернувший ноль, — единственный знак, что запись прошла мимо.
- Сектор флеша — около ста тысяч стираний: записи раз в сутки хватит на века, раз в секунду — на год-два; NVS распределяет износ по страницам сама.
Частые вопросы по теме урока
- Что означает второй аргумент в cfg.begin("app", true)?
- Второй аргумент — флаг readonly: true только читает, false читает и пишет. PutXX в read-only вернёт ноль, а begin(true) по несуществующему namespace вернёт false и накормит дефолтами. Открывает namespace только для чтения
- Порог с 12-битного АЦП (0-4095) — каким вызовом сохранить?
- UShort — два байта на 0-65535: 12-битному АЦП хватает с запасом. UChar обрезал бы диапазон на 255, строка для числа — лишний расход. putUShort
- getUShort("porog_u16", 2048) по ключу, которого в namespace нет, вернёт:
- Get-вызовы принимают запасное значение прямо в аргументе: ключа нет — вернётся заводская цифра. Компилятор при этом молчит, поэтому дефолт всегда осмысленный. 2048 — ваш дефолт из аргумента
- putUShort вернул 0. Что это скорее всего значит?
- Put-функции возвращают размер записанного, и ноль — единственный намёк, что значения в флеше не оказалось. Чаще всего виноват read-only режим или слишком длинное имя. Запись прошла мимо: read-only, ключ длиннее 15 символов или раздел забит
- clear() и remove() — в чём разница?
- clear() действует на namespace, открытый в текущий момент, remove() — на отдельный ключ внутри него. Уборку стоит делать при смене формата конфигурации. clear() вытирает весь открытый namespace, remove() — один ключ