Порог сработки термостата пришлось менять третий раз за месяц, а плата висела под потолком цеха. Ритуал каждый раз один и тот же: ноутбук, кабель, сборка, прошивка, лестница — полчаса ради одной цифры. Пока настройки зашиты в код, перепрошивка — единственный способ их поменять. Вытащите настройки в документ, который прилетает одной строкой по уже открытому каналу, — и ритуал сжимается до ввода {"heat":{"thr":22}}: плата применит новый порог на следующем проходе loop, без перезагрузки и без поездки.
Канал приёма строк я собирал в уроке про JSON-команды из монитора — там available, readStringUntil и диспетчер по ключу cmd. Загрузка конфига при старте с вычиткой из EEPROM и заводскими дефолтами — в уроке про конфиг из строки: там плата только просыпается, и конфликтов с работающим железом нет. Новое здесь — два: настройки едут вложенными группами и применяются по ходу работы, когда loop уже крутится и датчики опрашиваются.
План такой: разобрать, зачем группам быть вложенными, собрать конвейер применения с дефолтами на двух этажах — на группе и на ключе — и проверить его вживую: конфиг вводится в монитор, точка срабатывания светодиода сдвигается сразу. Плюс бюджет безопасности: что делать с цифрой, которая приедет явно неразумной.
Группы как пространства имён
Один порог живёт в конфиге без групп. Второй прибор на той же плате — и в документе встречаются два thr: порог нагрева и порог вентилятора. Плоские имена расползаются в heat_thr и fan_thr, документ перестаёт читаться глазами, а код обрастает сравнениями префиксов. Вложенная группа решает это структурой: {"heat":{"thr":22,"hyst":1},"fan":{"thr":30}} — у каждой настройки свой этаж, имена внутри короткие, и принимающий код ходит по своей группе, не глядя на соседнюю. Вторая выгода проявится позже: группу целиком можно печатать в ответе, применять целиком и сохранять целиком — группа как единица живёт во всех трёх конвейерах одинаково. Как выглядят цепочки ключей при разборе — в уроке про вложенные объекты.
С проверками вложенность подкидывает ловушку. containsKey отвечает только за корень документа: doc.containsKey("heat") скажет, что группа приехала, но молчит о том, что внутри неё. Ключ проверяется вторым вызовом уже на самой группе — на реальной плате это doc["heat"].containsKey("thr"). Пропустите второй этаж — и чтение по отсутствующему ключу молча вернёт ноль, а ноль станет порогом. В седьмой версии библиотеки механика та же, меняется только запись вложения: createNestedObject там записывается как to<JsonObject>() — переезд разобран в уроке про миграцию v6 в v7.
Дефолты на двух этажах
Конвейер применения я держу из четырёх шагов. Первый: строка разобралась — иначе ничего не трогаем вовсе. Второй: проверили группу, приехала ли heat. Третий: внутри группы проверили каждый ключ и подставили дефолт там, где ключа нет — int newThr = doc["heat"]["thr"] | thr; оператор возвращает правую часть на отсутствующем ключе — механика из урока по ссылке выше. Четвёртый: зажать числа в рабочую вилку и присвоить живым переменным. Отсутствие группы целиком — тоже норма: частичный конфиг правит только то, что в нём принесено, остальное живёт как жило. Обратная сторона частичности — дисциплина источника: если шлёте конфиг скриптом, печатайте в его лог ту же строку, что уехала в плату, тогда спор с платой сведётся к сравнению двух строк, а не к раскопкам кода скрипта.
Спорный вопрос — применять группу по частям или целиком. Моё правило: одна строка — одна группа целиком; не хватило ключа — группа остаётся старой целиком. Порог и гистерезис образуют пару: новый гистерезис поверх старого порога даёт гибрид, который хуже обоих исходных состояний, и поймать это в работающем логе трудно. Для одиночных настроек вроде имени узла правило можно ослабить — там пара не образуется. А чужие руки в канале — повод добавить проверку типов is<T>() на каждое поле: механика в уроке про is и as.
#include <ArduinoJson.h> DynamicJsonDocument doc(768); int thr = 24; // заводские значения — они же дефолтыint hyst = 1;String node = "lupa-3"; void applyHeat() { // чтение по цепочке ключей; оператор | возвращает старое значение, // если ключа нет int newThr = doc["heat"]["thr"] | thr; int newHyst = doc["heat"]["hyst"] | hyst; if (newThr < 10 || newThr > 40) { Serial.println("thr za granicej 10..40: gruppa ne primenena"); return; } thr = newThr; hyst = constrain(newHyst, 0, 5); Serial.print("primeneno: thr="); Serial.print(thr); Serial.print(" hyst="); Serial.println(hyst);} void setup() { Serial.begin(9600); pinMode(12, OUTPUT); Serial.print("uzel "); Serial.print(node); Serial.println(" gotov k konfigu");} void loop() { if (Serial.available()) { String line = Serial.readStringUntil(10); if (line.length() == 0) return; // на настоящей плате: DeserializationError err = deserializeJson(doc, line); // и проверка if (err); в движке симулятора 1 = успех, 0 = не разобралось int ok = deserializeJson(doc, line); if (ok == 0) { Serial.println("stroka ne razobralas: konfig ne primenen"); } else if (doc.containsKey("heat")) { applyHeat(); } else if (doc.containsKey("name")) { node = doc["name"].as<String>(); Serial.print("imya uzla: "); Serial.println(node); } else { Serial.println("grupp ne uznal: heat / name"); } doc.clear(); }}Порядок пробы: сначала {"heat":{"thr":22}} — реплика и сдвиг точки срабатывания на ручке; потом {"heat":{"thr":35,"hyst":2}} — порог и гистерезис вместе; потом {"heat":{"hyst":2}} без порога — и группа не применится целиком; потом {} — реплика про незнакомые группы; потом строка без фигурной скобки. Плата на каждый случай отвечает конкретной репликой и продолжает работать: промах по конфигу — ветка вежливого отказа, а не перезагрузка.
Честная оговорка про движок: поимённое чтение doc["heat"]["thr"] в симуляторе не эмулировано — значение вытащено из печати документа через serializeJson и indexOf, и в коде помечено комментарием. На настоящей плате это одно чтение по цепочке ключей, как в скетче для платы. Проверка формы строки — предохранитель движка: битую строку он считает аварией; на плате она просто вернётся из deserializeJson с кодом, который вы печатаете.
Кто шлёт конфиг на практике — у меня было три источника, и все они шли в одну ветку. Наладчик вводил строку из монитора при пусконаладке: порог, гистерезис, имя узла. Скрипт ночной калибровки слал пересчитанный порог раз в сутки по своему расписанию. Бригада на объекте меняла уставку через брокера — та же строка приезжала из MQTT-топика, и плата не отличала её от ручного ввода. Общее у всех трёх одно: после применения печатается факт — thr, hyst — и этот факт потом хранится в логе наладки. Без реплики спор «а какой порог стоит сейчас» решается только перечитыванием скетча, с репликой — одним взглядом в журнал.
Канал и границы
Монитор — канал человека; постоянный партнёр платы — второй скетч или брокер. Конвейер от транспорта не зависит: та же строка прилетает по UART со второй платы или из MQTT-топика, и ветки применения не меняются. Меняется источник неразумных цифр: человек хотя бы иногда смотрит на значение, скрипт — никогда, поэтому зажим в рабочую вилку перед присваиванием — не украшение, а обязательный шаг конвейера.
Хотите, чтобы конфиг пережил вилку из розетки, — после применения его надо сохранить: serializeJson в буфер, запись в EEPROM по событию; ресурс ячеек и порядок записи разобраны в уроке про конфиг в EEPROM, а валидация каждого поля — в уроке про валидацию входа. Здесь я умышленно остаюсь в RAM: применение на лету не требует ни одной записи в память, и это его главное достоинство — цена та же, что у любой переменной: сброс питания вернёт заводские значения.
| Способ правки поведения | Цикл изменения | Что для этого нужно |
|---|---|---|
| Перепрошивка | минуты на столе, полчаса с выездом к плате | ноутбук, кабель, физический доступ |
| Конфиг-строка на лету | секунды: одна строка в канал | живой канал приёма и зажим значений |
| Та же строка плюс EEPROM | секунды, переживает сброс питания | запись только по событию: ресурс ячеек конечен |
| Конфиг-файл на SD | правка на ПК, читается при старте | держатель карты и отдельное питание шины |
- 1. doc.containsKey("heat") вернул 1. Что это гарантирует?
- 2. Пришла строка {"heat":{"hyst":2}} без порога. Как поступит скетч урока?
- 3. Зачем перед присваиванием зажимать пришедший порог в вилку 10..40?
- 4. doc.clear() после применения конфига — зачем?
- 5. Чем применение на лету отличается от загрузки при старте?
- Конфиг — одна строка по каналу: частичный состав, применяется на лету, перезагрузка и перепрошивка не нужны
- containsKey проверяет корень; ключ внутри группы — отдельной проверкой на самой группе
- Группа применяется целиком: не хватает ключа — старая версия живёт целиком, гибридов не делать
- Каждая цифра зажимается в рабочую вилку до присваивания — канал не знает физики устройства
- doc.clear() после применения: ключи прошлой строки не доживают до следующего разбора
Частые вопросы по теме урока
- doc.containsKey("heat") вернул 1. Что это гарантирует?
- containsKey видит только корень документа: группа есть — но есть ли в ней thr, проверяет отдельный вызов уже на самой группе, doc["heat"].containsKey("thr") на реальной плате. Что в документе есть группа heat — о ключах внутри она ничего не говорит
- Пришла строка {"heat":{"hyst":2}} без порога. Как поступит скетч урока?
- Правило атомарности: одна строка — одна группа целиком. Гибрид из нового гистерезиса и старого порога хуже обоих исходных состояний, поэтому группа остаётся старой до строки с полным составом. Не применит группу вовсе: для неё нужен и порог
- Зачем перед присваиванием зажимать пришедший порог в вилку 10..40?
- Канал приёма не спрашивает пароль и не знает физики: строка с девятью тысячами разберётся без запинки. Зажим — единственное место, где бессмыслица превращается в безопасное значение; за вилкой группа не применяется вовсе. Канал не проверяет разумность: без вилки любая цифра из порта станет порогом
- doc.clear() после применения конфига — зачем?
- Документ живёт между разборами: без очистки в нём копятся ключи всех прошлых строк, при печати ответа уезжает лишнее, а в v6 пул тихо кончается. Одна строка перед новым разбором снимает весь класс проблем. Чтобы ключи прошлой строки не дожили до следующего разбора
- Чем применение на лету отличается от загрузки при старте?
- Разбор строки одинаков; разница в адресате. При старте настройки применяются до главного цикла, на лету — в работающий loop, и новое значение управляет железом с первого такта. Присваивание живым переменным видно на следующем проходе loop: перезагрузка не нужна