Полив в теплице включался по порогам, зашитым в скетч: ниже восемнадцати — насос стоит, выше двадцати шести — открывается проветривание. Каждое «подвинь на градус» означало поездку: ноутбук, кабель, перепрошивка. За лето таких поездок набралось полтора десятка — прежде чем я признал: настройка устройства не должна требовать ноутбука. Настройки в переменных RAM я тоже пробовал — до первого скачка питания в грозу: плата перезагрузилась, пороги вернулись к заводским, а узнал я об этом по мокрому полу.
Дальше была вторая крайность: EEPROM.write каждого порога в каждом проходе loop. За сезон я выжёг две ячейки: сто тысяч циклов записи у ATmega328P звучит как вечность, пока не пишешь в одну ячейку тысячу раз в минуту. Обе катастрофы лечатся одним ходом: конфиг — одной JSON-строкой, применение — по команде, хранение — EEPROM.update, который касается ячейки только при изменении.
Третье «решение» я видел у других и не повторял: держать настройки в комментарии в начале скетча и править их поиском-заменой. Это та же перепрошивка, только с лишним шагом, и легенда рассыпается при первом конфликте версий. JSON-строка выигрывает у всех трёх вариантов по одной причине: она приходит к устройству сама, а не устройство едет к ней.
В этом уроке — полный цикл настройки устройства: строка приходит по Serial, разбирается, проверяется на здравый смысл, применяется на лету и ложится в энергонезависимую память. Всё это гоняется в симуляторе вживую — с командами из монитора порта.
Один документ вместо десяти переменных
Конфиг — объект, у каждого поля есть имя: {"name":"teplica1","mode":2,"th_lo":18,"th_hi":26}. Пятьдесят байтов описывают всё устройство: как его зовут, в каком режиме работать и где границы. Конфиг — единственный документ, который вы пишете сами и сами же потом читаете с чужих устройств: у него нет автора-сервера, который мог бы поменять порядок полей. Порядок применения я держу жёсткий: строка, deserializeJson, проверка кода разбора — мы её разбирали в уроке про первый разбор — потом проверка каждого ключа через containsKey, проверка диапазонов, присваивание переменным и только после этого энергонезависимая память.
containsKey решает главную проблему частичного конфига: прислали только пороги — режим остаётся прежним. Молчаливое чтение отсутствующего ключа даёт ноль, и без проверки вы получите th_lo=0: JSON безупречен, устройство сломано по логике. Диапазоны — второй рубеж: порог 300 синтаксически безупречен, но насос он не спасёт. Моё правило: каждое поле проходит две проверки — есть ли оно вообще и в разумных ли пределах.
Подписать поля пояснениями не выйдет: комментарии в JSON не живут — мы это фиксировали ещё в уроке про синтаксис. Легенда живёт рядом: ключ "_comment" со строкой-пояснением, который скетч просто не читает, или README в репозитории. И держите состав полей единым для всех устройств серии: конфиг, у которого mode есть, а th_hi забыли, — это два скетча под видом одного.
Канал доставки конфига — взаимозаменяемая деталь. В этом уроке строка приходит по Serial-проводу; тот же скетч примет её по Bluetooth-модулю, прочитает с SD-карты или получит подпиской из брокера. Приёмник не знает, откуда пришла строка, — он знает только её содержимое. Поэтому приёмы этого урока переезжают на другой канал одним движением: до deserializeJson всё равно, какой была дорога.
И порядок на приёме — «сначала все проверки, потом все изменения». Применять поля по ходу валидации я перестал после случая, когда половина конфига применилась, а вторая была отвергнута диапазоном: устройство застыло с нижним порогом от нового конфига и верхним от старого. Сначала соберите новые значения во временные переменные, проверьте их вместе — и только потом присваивайте рабочим и пишите в память.
EEPROM хранит по байту на ячейку — тысяча ячеек у ATmega328P. Режим 0-2 кладётся одним байтом, целые градусы — тоже: порог 20.5 придётся либо округлить, либо разложить на байты — арифметику с байтами мы считали в уроке про int и float в EEPROM. Строку имени я в EEPROM не кладу вовсе: «teplica1» — это девять байтов плюс терминатор, десять ячеек против одной на порог, и для неё честнее EEPROM.put со структурой — отдельная история со своими граблями.
Свежая плата читает нетронутые ячейки как 255 — готовый маркер «конфига ещё не было»: скетч видит 255 и записывает заводские значения. Запись идёт через EEPROM.update: он сравнивает значение с ячейкой и пишет только при разнице, поэтому ресурс в сто тысяч циклов перестаёт гореть. Цену перезаписи мы считали в уроке про износ ячеек — update там главный герой. На ESP32 всё это с оговоркой: библиотека эмулирует EEPROM во flash, перед чтением нужен begin(размер), после записи — commit(), иначе изменения не сохранятся.
Чтение настроек живёт в setup: до главного цикла плата восстанавливает пороги и режим, дальше loop работает с переменными и память не трогает. Холодный старт выглядит так: плата включилась, setup читает три ячейки, переменные получают пороги, вердикт печатается ещё до первого опроса датчика — устройство за полсекунды уже знает, кто оно и по каким правилам живёт. Так скетч проходит и грозу, и перепрошивку: конфиг в EEPROM переживает и то и другое.
#include <ArduinoJson.h>#include <EEPROM.h> JsonDocument doc; int cfgMode = 2;int thLo = 18;int thHi = 26; void saveConfig() { EEPROM.update(0, cfgMode); // update: пишет только при изменении EEPROM.update(1, thLo); EEPROM.update(2, thHi);} void setup() { Serial.begin(9600); if (EEPROM.read(0) == 255) { // Свежая плата: ячейки читаются как 255 — заводской конфиг deserializeJson(doc, "{\"mode\":2,\"th_lo\":18,\"th_hi\":26}"); cfgMode = doc["mode"]; thLo = doc["th_lo"]; thHi = doc["th_hi"]; saveConfig(); Serial.println("Konfig zavodskoj, zapisal v EEPROM"); } else { cfgMode = EEPROM.read(0); thLo = EEPROM.read(1); thHi = EEPROM.read(2); Serial.println("Konfig vosstanovlen iz EEPROM"); }} void loop() { if (Serial.available() > 0) { String cmd = Serial.readStringUntil('\n'); cmd.trim(); if (cmd.startsWith("cfg ")) { // Формат команды: cfg {"mode":1,"th_lo":20,"th_hi":27} DeserializationError err = deserializeJson(doc, cmd.substring(4)); if (err) { Serial.println("Konfig ne chitaetsya"); return; } if (!doc.containsKey("th_lo") || !doc.containsKey("th_hi")) { Serial.println("Net porogov, konfig ne prinyat"); return; } int lo = doc["th_lo"]; int hi = doc["th_hi"]; if (lo < 0 || lo > 40 || hi < 0 || hi > 40 || lo >= hi) { Serial.println("Porogi vne diapazona"); return; } thLo = lo; thHi = hi; saveConfig(); Serial.print("Prinyato: lo="); Serial.print(thLo); Serial.print(" hi="); Serial.println(thHi); } }}Команды настройки без перепрошивки
В симуляторе тот же цикл в интерактиве: заводской конфиг разбирается из JSON-строки, применяется к переменным, кладётся в EEPROM, и скетч ждёт команд. Команды — строки до перевода строки, у каждой свой префикс: th_lo и th_hi меняют пороги, mode выбирает режим из трёх, t подставляет показание датчика и просит вердикт, def возвращает заводскую строку, config показывает и переменные, и копию в EEPROM. Проверка th_lo меньше th_hi — общая для обеих команд: пороги обязаны образовать вилку, иначе логика устройства рассыпается.
Две честные оговорки. Первая: ключи читаются точечной формой doc.th_lo — на плате она равнозначна doc["th_lo"], но движок понимает только точку. Вторая: в симуляторе EEPROM живёт, пока жив скетч, — перезапустите симуляцию, и память обнулится; на плате ячейки переживают и перезагрузку, и отключение питания, что и составляет смысл энергонезависимости. Заводская печать и вердикт в начале работы — это те же переменные, только применённые до первого опроса датчика.
Префикс в команде — не украшение. Считать порог голым Serial.parseFloat() можно, но тогда скетч не отличит th_lo 20 от th_hi 20: оба дадут двадцатку в одну и ту же переменную. Префикс выбирает, куда класть число, а подстрока после пробела его доставляет; мусор после числа toFloat() молча превратит в ноль. Попробуйте в симуляторе th_lo abc: ноль меньше верхнего порога — и нулевой порог уйдёт в EEPROM вместе с настоящими; ограничение 0-40 снизу добавьте сами — это упражнение на собственную валидацию.
| Поле | Тип | Разумный диапазон | Где хранится |
|---|---|---|---|
| name | строка | до 15 символов | RAM и пакет; в EEPROM не идёт |
| mode | число 0-2 | 0 ручной, 1 по часу, 2 авто | EEPROM, 1 байт |
| th_lo | число | 0-40, меньше th_hi | EEPROM, 1 байт, целые градусы |
| th_hi | число | 0-40, больше th_lo | EEPROM, 1 байт, целые градусы |
- Перепутать пороги. Отправьте th_hi 12 при нижнем 18: скетч откажет — верхний обязан быть больше нижнего. Теперь th_hi 28: применится и ляжет в EEPROM, в строке eeprom: hi= сменится с 26 на 28.
- Выйти за режимы. mode 7 скетч отвергнет с подсказкой, mode 1 применится. Защита от «валидного, но бессмысленного» — та же, что на плате.
- Прогнать вердикт. t 19.2 при нижнем пороге 20 — насос включится. Верните def и проверьте, что пороги вернулись к заводским и в EEPROM тоже.
- Незнакомая команда. Отправьте что-нибудь постороннее: скетч ответит списком. Молчание на мусорную команду на плате стоит ночи без полива — отвечайте всегда.
- Конфиг — один JSON-объект с именованными полями: строка, deserializeJson, containsKey по каждому полю, диапазоны, переменные, память
- Отсутствующее поле читается молча нулём: containsKey обязателен перед каждым чтением частичного конфига
- Свежая ячейка ATmega328P читается как 255 — готовый маркер «конфига не было»; заводские значения пишутся сами
- EEPROM.update пишет только при изменении значения — настройкам сто тысяч циклов хватит на годы
- Байт на ячейку: режим и целые градусы ложатся в один байт; строка имени в EEPROM не идёт без put и структуры
- 1. Что читает нетронутая ячейка EEPROM на свежей ATmega328P?
- 2. Чем EEPROM.update лучше write для хранения настроек?
- 3. В конфиге нет ключа th_lo, разбор прошёл успешно. Что вернёт чтение без проверки?
- 4. Порог 300 пришёл в синтаксически верном JSON. Какая проверка его остановит?
- 5. Сколько байтов EEPROM займёт режим работы из трёх значений 0-2?
Конфиг приехал, проверился, лёг в память — и всё это без единой перепрошивки. Заметьте, что сам JSON никуда не записывался: в EEPROM ушли только числа, а строка-конфиг осталась способом доставки. Тот же приём работает и в обратную сторону: устройство само рассказывает о себе JSON-ом, и именно такой пакет открывает публикацию состояния в MQTT-брокер и отчёты на сервер. А когда настроек станет больше, чем байтов в ячейках, документ можно сериализовать целиком в энергонезависимую память — это отдельная ступень. Домашнее задание с платой: заведите три режима работы через switch-case и перевесьте конфиг на них: mode из JSON станет выбором ветки, а пороги — условиями внутри.
Частые вопросы по теме урока
- Что читает нетронутая ячейка EEPROM на свежей ATmega328P?
- Нетронутые ячейки читаются как 255. Это удобный маркер: если в ячейке 255 — конфига ещё не было, пора писать заводские значения. 255
- Чем EEPROM.update лучше write для хранения настроек?
- update сравнивает значение с ячейкой и пишет только при разнице. Ячейка выдерживает около 100 000 циклов записи: write в каждом проходе loop выжигает её за считанные часы, update — годы. Не касается ячейки, если значение не изменилось
- В конфиге нет ключа th_lo, разбор прошёл успешно. Что вернёт чтение без проверки?
- Чтение отсутствующего ключа молча даёт ноль. Для порога это катастрофа: насос включится на любом морозе, потому что ноль ниже любого разумного нижнего предела. Спасает containsKey перед чтением — и правило «не пришло — не применяем», которое делает частичные конфиги безопасными. Ноль — молча
- Порог 300 пришёл в синтаксически верном JSON. Какая проверка его остановит?
- JSON безупречен — разбор и containsKey пройдут без замечаний. Спасает только проверка диапазона: каждое поле проходит две проверки — наличие и границы. Одна без другой не защищает. Проверка диапазона значений
- Сколько байтов EEPROM займёт режим работы из трёх значений 0-2?
- Число 0-2 помещается в один байт. Целые градусы тоже байт; float требует четырёх байтов и своей арифметики — про это отдельный урок раздела EEPROM. Один