Тепличный логгер прожил на карте два сезона: раз в пять минут пишет строку в data.csv, и карта наполовину пустая. А порог обогрева и интервал записи всё это время были зашиты в скетч константами. Каждый раз, когда я менял уставку с 12 на 14 градусов, повторялся один ритуал: ноутбук, кабель, IDE, компиляция, поездка или прогулка до теплицы. Ровно в тот момент, когда я в четвёртый раз перешивал плату ради одной цифры, до меня дошло: у устройства уже есть карта, и половина её пуста.
Так рядом с data.csv появился config.json. Поменял пороги в блокноте на компьютере, переткнул карточку, ресет — настройки применились, IDE не понадобилась вовсе. Разбор при этом тот же самый, что и для сетевых ответов: строка входит в deserializeJson, значения выходят по имени. Типовой конфиг и его разбор мы уже проходили в уроке про конфиг из JSON, а механику крепления к SD-карте разобрал соседний раздел в материале про JSON-конфиг на карте; здесь я соединяю обе половины и говорю про то, о чём в них умолчали.
План такой: путь «файл → строка → документ» и цена каждого шага, значения по умолчанию для полей, которых в файле нет, главная грабля карты — файл, открытый в двух местах сразу, — и таблица, где каким настройкам жить честнее. Конфиг из урока я покажу и в симуляторе: движок книги SD не эмулирует, но всю механику разбора и дефолтов можно покрутить на строке прямо в браузере.
Файл → строка → документ
На плате путь из трёх шагов. SD.open() открывает файл, f.readString() вычитывает его целиком в String, deserializeJson() разбирает строку в документ. Библиотеке всё равно, откуда пришла строка — из HTTP, из монитора или с карты: механика разбора одна, и всё, что вы умеете по отношению к сетевым ответам, переносится сюда без единой правки. Особенность именно SD-варианта в другом: файл живёт доли секунды. Открыли, вычитали, закрыли — карта снова свободна, и больше от конфига ничего не нужно.
readString() вычитывает до конца файла одним движением, и для конфига в сто-двести байт это лучший вариант. Плата платит за простоту второй копией данных: строка лежит в ОЗУ целиком, а следом за ней пул документа. На Uno конфиг в триста байт занимает в худший момент шестьсот с лишним — строка плюс разобранная копия, и обе умирают через секунду, когда выйдут из области видимости. Если конфиг подрастёт до килобайта, вспомните построчный вариант из урока про чтение SD по частям: там строка собирается через readStringUntil и разбирается по мере поступления.
Вторая половина урока — значения по умолчанию. Человек правит конфиг блокнотом, и поля в нём живут своей жизнью: сегодня дописал tmin, а интервал забыл, завтра удалил лишнее и заодно снёс имя узла. doc["interval"] | 60 — оператор | возвращает правую часть всякий раз, когда слева null-прокси от отсутствующего ключа. Длиннее, но явнее, та же проверка через doc.containsKey("interval") и тернарник — именно она стоит в симуляторе ниже, потому что движок понимает containsKey, а оператор | оставил настоящей плате. Главное правило одно: дефолт живёт в одном месте, рядом с чтением, а не размазан по скетчу пятью if-ами.
#include <ArduinoJson.h>#include <SD.h>#include <SPI.h> JsonDocument cfg; // v6: StaticJsonDocument<256> cfg; const char* IMYA = "/config.json";int intervalSek = 60; // значения по умолчанию — до первогоfloat tmin = 12.5; // успешного разбора они и работают void primenitKonfig(String tekst) { DeserializationError err = deserializeJson(cfg, tekst); if (err) { Serial.print("konfig ne razobralsya: "); Serial.println(err.c_str()); return; // прежние значения остаются в силе } // absent-pole возвращает правую часть оператора | intervalSek = cfg["interval"] | intervalSek; tmin = cfg["tmin"] | tmin; const char* node = cfg["node"] | "bez-imeni"; // дефолт не отменяет проверку границ intervalSek = constrain(intervalSek, 5, 3600); if (tmin < -20.0 || tmin > 40.0) tmin = 12.5; Serial.print("konfig primen: node="); Serial.print(node); Serial.print(" interval="); Serial.print(intervalSek); Serial.print(" tmin="); Serial.println(tmin, 1);} void setup() { Serial.begin(9600); if (!SD.begin(10)) { // CS — D10 на моих модулях Serial.println("karta ne podnyalas: rabotaem na defoltah"); return; } File f = SD.open(IMYA, FILE_READ); if (!f) { Serial.println("config.json ne naiden: defolty"); return; } String tekst = f.readString(); // файл целиком в String f.close(); // закрыли сразу — карту ждёт логгер primenitKonfig(tekst);} void loop() {}По косточкам. SD.begin(10) стоит до всего остального и один раз: карта поднимается секунду-полторы, и пока она не поднялась, open вернёт false. Оба отказа — нет карты, нет файла — уходят в ветку с дефолтами, и это осознанное решение: логгер со свежей прошивкой обязан работать с пустым слотом, а не лежать. На моём узле дефолты совпадают с рабочими уставками, поэтому потерянная карточка означает лишь «параметры прежние», о чём строка в мониторе и говорит.
Проверка err — не украшение. Карта вытащена на бегу, конфиг дописан в телефоне в чужой кодировке, кавычка потерялась — разбор вернёт код, и err.c_str() назовёт его в мониторе: InvalidInput у кривой строки, NoMemory у разросшегося файла. Ветка с return держит все прежние значения в силе: конфиг применяю только после успеха, частично применившийся разбор для настроек хуже неприменившегося. Коды и их привычки разобраны в уроке про диагностику разбора — здесь работает та же таблица.
Третье, на что я потратил отдельный вечер: дефолт спасает от отсутствующего поля, но не от бессмысленного. interval: 3 — поле есть, разбор успешный, логгер честно начинает писать пятьсот строк в сутки и забивает карту за неделю. Поэтому после разбора идут границы: constrain по интервалу и проверка tmin на вменяемый диапазон. Полный набор проверок входа — обязательные ключи, типы, диапазоны — собран в уроке про валидацию JSON; для конфига я беру оттуда минимум: границы после каждого числового поля.
Отдельно про то, когда конфиг перечитывается. Один раз в setup() — самый предсказуемый вариант: включили — применили. На живом узле я добавляю перечитывание по команде из монитора: тот же primenitKonfig(), вызванный из loop, меняет порог без ресета, пока логгер стоит в паузе между записями. Симметричная операция — сохранение: serializeJson(cfg, файл) пишет документ обратно на карту, и узел запоминает пороги, применённые на месте. Ресурс карты — десятки тысяч циклов записи, поэтому сохраняю по событию, а не по таймеру; счётчики и калибровки, которые правятся чаще, честнее держать в EEPROM, о чём разобрано в уроке про конфиг в EEPROM.
Грабли карты
Грабля, на которую я потратил вечер и половину карты: конфиг читался, но половина полей приходила мусором, а иногда разбор падал на ровном месте. Причина оказалась в архитектуре, а не в JSON: в том же скетче логгер держал открытым data.csv, и мой новый код открывал config.json вторым File на ту же карту. Библиотека SD держит общий кэш сектора и позиции, и два открытых файла начинают читать вперемешку: фрагмент конфига, кусок CSV-строки, снова конфиг — deserializeJson честно давится тем, что ему подсунули.
Лечение простое и обязательное: один файл за раз. Конфиг читаю в паузе между записями — закрыл CSV, открыл config.json, вычитал, закрыл, снова открыл лог. Скобки open-close из код-блока стоят ровно вокруг readString, и расширять эту зону незачем: файл нужен долю секунды. Правило то же, что и у сетевых ответов — источник живёт до конца разбора, но здесь дополнительный смысл: карта одна, и конкурентов у конфига много.
Вторая порция мусора приходит от железа, а не от кода. Модуль на пять вольт без делителя уровней на MISO, провода длиннее двадцати сантиметров на макетке, карта, вытащенная на работающей плате, — строка ломается ещё до разбора, и библиотека ни при чём. SD.begin после горячей замены карточки библиотека сама не повторит: перезапуск платы — часть ритуала смены конфига. Про питание и уровни модулей у меня есть отдельный разбор в соседнем разделе; лог-файл, вокруг которого весь этот танец, устроен в материале про логирование на SD.
| Место хранения | Правка без перепрошивки | Когда выбираю |
|---|---|---|
| Константы в скетче | нет: правка = сборка и прошивка | то, что не менялось годами: пин датчика, версия протокола |
| EEPROM | да, по команде из Serial | калибровки и счётчики: ресурс записи ~100 тысяч, объём — байты |
| JSON на SD | да, блокнотом, без подключения платы | пороги, интервалы, имена узлов: объём хоть килобайт, карта уже в устройстве |
| JSON по сети | да, из дома, для всех узлов разом | ферма узлов, где ходить к каждому дороже, чем держать канал |
Симулятор ниже показывает второй и третий шаг пути — строку и документ, — потому что SD и File движок книги не эмулирует. В скетче конфиг лежит константой configJson: та самая строка, которую на плате даёт f.readString(). Вся механика разбора, дефолтов и применения — настоящая: deserializeJson, containsKey, чтение по ключам.
Что попробовать. Кнопка D2 перечитывает конфиг — имитация «переткнул карточку, нажал ресет». Ручка A0 играет температуру в теплице, и LED показывает обогрев: горит, пока температура ниже tmin из конфига. Второй источник — монитор: шлите {"interval":30,"tmin":15} и оба поля применятся; шлите {"tmin":15} — интервал останется прежним, потому что absent-поле вернуло дефолт. Кривую строку без скобки движок отвергнет так же, как плату.
- 1. Поле interval в config.json отсутствует. Что вернёт cfg["interval"] | 60?
- 2. Логгер держит открытым data.csv, и тот же скетч открывает config.json вторым File. Что читается?
- 3. deserializeJson вернул err: кавычка в конфиге потерялась. Что со значениями interval и tmin?
- 4. Зачем f.close() сразу после readString()?
- 5. В конфиге interval: 3. Разбор успешный. Что добавляю после него?
- Путь данных один и тот же для любого источника: SD.open → readString → deserializeJson; файл живёт долю секунды
- Дефолт для absent-поля — doc["interval"] | 60 или containsKey с тернарником; значение по умолчанию живёт рядом с чтением
- Разбор сорвался — прежние значения в силе: конфиг применяется только после Ok, err.c_str() называет причину
- Один File на карту за раз: два открытых файла читаются вперемешку — мусор выглядит как кривой JSON
- Дефолт не отменяет валидацию: constrain по границам после каждого числового поля
Частые вопросы по теме урока
- Поле interval в config.json отсутствует. Что вернёт cfg["interval"] | 60?
- Оператор | — штатный способ дефолта: слева null-прокси, справа значение по умолчанию. Тот же результат даёт containsKey с тернарником. 60: оператор возвращает правую часть при null-прокси
- Логгер держит открытым data.csv, и тот же скетч открывает config.json вторым File. Что читается?
- Библиотека SD не рассчитана на два открытых файла: общий кэш сектора портит чтение обоих. Один файл за раз, конфиг — в паузе между записями. Мусор: кэш сектора и позиции сбиты, поля едут вперемешку
- deserializeJson вернул err: кавычка в конфиге потерялась. Что со значениями interval и tmin?
- Ветка с return после err не трогает рабочие переменные. Частично применённый конфиг для настроек хуже неприменённого. Останутся прежними: применяю конфиг только после успешного разбора
- Зачем f.close() сразу после readString()?
- Файл нужен долю секунды. Пока он открыт, логгер не может писать, а второй open на той же карте — прямая дорога к мусору. Карту ждёт логгер: один файл за раз, держать открытым дольше чтения незачем
- В конфиге interval: 3. Разбор успешный. Что добавляю после него?
- Absent-поле ловит оператор |, бессмысленное значение — только границы. Оба механизма стоят рядом с чтением. constrain(intervalSek, 5, 3600): дефолт не отменяет проверку границ