Отладка через монитор порта быстро перерастает сама себя. Сначала команда выглядит как led 1 — префикс и число, разбирается в две строки. Через месяц режимов пять, у половины есть параметр, и каждая новая фича добавляет ветку в if и очередное сравнение строк. Я упёрся ровно в это, когда в протокол гирлянды поехали интенсивность, скорость и три имени эффекта: парсер на плате и генератор команд на ноутбуке разрастались синхронно, и каждое изменение приходилось вносить дважды. JSON-команда одной строкой гасит этот рост: {"cmd":"blink","hz":3} приходит, разбирается стандартной библиотекой, ключи видны глазами — а добавление нового поля не трогает ни одной старой ветки. Как живут CSV-протоколы до этого этапа, разобрано в уроке про strtok и atoi — там же и причина, по которой запятая внутри значения рвёт строку пополам.
У формата есть тихий бонус: документ сам подсказывает, что сломалось. Пришло {"cmd":"led"} без state — containsKey честно скажет, что параметра нет, и ветка вернёт в монитор список того, что плата умеет. В префиксном протоколе тот же случай выглядит как команда led с мусором вместо аргумента, и вы вручную разбираетесь, что именно съел sscanf. Официальный пример JsonParserExample у ArduinoJson подсовывает парсеру строку-константу, зашитую в скетч; для живого порта вокруг разборки всегда достраивают копилку байтов до терминатора. Этим конвейером и займёмся — он короче, чем кажется, и собирается из трёх знакомых деталей.
План на урок: принять строку из монитора, разобрать её, разложить по ключам и заставить светодиод подчиняться — зажигание, гашение и мигание с выбранной частотой. Всё запускается прямо здесь: движок симулятора умеет и Serial, и ArduinoJson, так что интерактив ниже — не картинка, а настоящий ввод строк с клавиатуры в Serial Monitor.
Приём: строка, разбор, действие
Конвейер из четырёх шагов. Первый — Serial.available(): есть ли что читать. Второй — readStringUntil(10): аргумент — код перевода строки, он же символ новой строки; функция копит байты, пока не встретит терминатор, и отдаёт накопленное как String. У копилки есть таймаут — 1000 мс по умолчанию: если терминатор так и не приехал, через секунду тишины функция вернёт всё, что успела собрать. Третий шаг — deserializeJson(doc, line): строка превращается в документ. Четвёртый — containsKey и чтение ключей, после чего диспетчер выбирает ветку по значению cmd. Каждый шаг короткий, и вместе они помещаются в один экран кода.
Диспетчер я держу плоским: одна ветка на команду, внутри ветки — чтение параметров со своими containsKey. Отсутствие ключа трактуется как повод для дефолта, а не как катастрофа: {"cmd":"blink"} без hz пусть мигает с частотой в один герц, но живёт и отвечает. Проверки типов и аккуратную валидацию каждого поля я разбирал в уроке про containsKey, is и as — здесь беру только то, что нужно команде из монитора, и не тащу весь арсенал.
Одна оговорка про возврат разборки, чтобы вы не спотыкались о разницу между симулятором и платой. В движке этого курса deserializeJson возвращает 1 на удачном разборе и 0 на строке, которая не разобралась. На настоящей плате каноничнее тип DeserializationError: функция возвращает код, а проверка выглядит как if (err) — имена кодов и печать через err.c_str() разобраны в уроке про диагностику разборки. Логика веток от этого не меняется — меняется только направление проверки, и в скетчах ниже оба варианта помечены комментариями.
#include <ArduinoJson.h> DynamicJsonDocument doc(768); int mode = 0; // 0 погашен, 1 горит, 2 мигаетint hz = 1;bool blinkOn = false;unsigned long lastBlink = 0; void setup() { Serial.begin(9600); pinMode(12, OUTPUT); Serial.println("gotov: zhdu JSON-komandu");} void loop() { // мигание без delay: millis-таймер из урока про планировщик if (mode == 2 && millis() - lastBlink >= 500 / hz) { lastBlink = millis(); blinkOn = !blinkOn; digitalWrite(12, blinkOn ? HIGH : LOW); } if (Serial.available()) { String line = Serial.readStringUntil(10); // 10 = перевод строки if (line.length() > 0) { // на настоящей плате: DeserializationError err = deserializeJson(doc, line); // и проверка if (err) — здесь использован возврат в числах int ok = deserializeJson(doc, line); if (ok) { if (doc.containsKey("cmd")) { String cmd = doc["cmd"]; if (cmd == "led" && doc.containsKey("state")) { int state = doc["state"]; mode = state ? 1 : 0; digitalWrite(12, state ? HIGH : LOW); Serial.print("led -> "); Serial.println(state); } else if (cmd == "blink" && doc.containsKey("hz")) { hz = doc["hz"]; if (hz < 1) hz = 1; if (hz > 5) hz = 5; mode = 2; Serial.print("blink -> "); Serial.print(hz); Serial.println(" Гц"); } else if (cmd == "state") { Serial.print("mode="); Serial.print(mode); Serial.print(" hz="); Serial.println(hz); } else { Serial.println("cmd: led / blink / state"); } } else { Serial.println("klyucha cmd net: smotri spisok komand"); } } else { Serial.println("stroka ne razobralas: ne JSON"); } } }}Порядок пробы такой: сначала led со state 1 и 0, потом blink с частотой от 1 до 5, потом state — и потом невыполнимые команды: led без state, blink без hz, неизвестное имя вроде ping. Плата на каждый случай отвечает конкретной репликой и остаётся живой: промах по ключу — это ветка вежливого отказа со списком того, что она умеет, а не перезагрузка. Именно это поведение и есть цель урока: монитор — не кнопочный интерфейс, а канал, по которому ходит документ, и документ может быть каким угодно. Плохая команда — обычное рабочее состояние канала, а не повод для драмы; плата, которая молчит на незнакомый ввод, хуже платы, которая отвечает списком.
Ответ плате я оставляю в двух видах: короткое действие вроде led -> 1 для человека и список допустимых команд при промахе. Вторая привычка — печатать разобранный документ обратно через serializeJson — завелась у меня, когда команд стало больше трёх: эхо показывает, что плата поняла именно вашу строку, а не соседнюю из истории ввода. Как собрать такой ответный документ своими руками — в уроке про serializeJson; там же и про то, почему для ответа машине хватит компактной печати.
Одна честная оговорка про симулятор: движок считает по-настоящему битую строку аварией и не рисует её разбор, поэтому в модели стоит проверка формы — строка без открывающей фигурной скобки отсеивается до разборки репликой про JSON. На настоящей плате такой предохранитель не обязателен: строка уходит в deserializeJson как есть и возвращается с кодом, который вы печатаете. Разница только в том, где вы решаете, что делать с мусором — до разборки или после неё; на обеих сторонах плата живёт и отвечает.
Цена формата и его границы
За самонесущие ключи платят памятью. Буфер строки — десятки байт; документ под разбор в этом уроке — 768 байт на Uno с её 2 КБ SRAM, и место это делится с Serial и остальными библиотеками. Для команд из четырёх-пяти полей размен осмысленный: вы получаете самодокументируемый протокол, который переживёт десяток правок без смены формата. Если документ пойдёт расти, посчитайте его заранее — методика оценки в уроке про measureJson занимает минуту и экономит вечер с внезапными перезагрузками по нехватке памяти. Правило простое: документ — не место для экономии на грани, а цена предсказуемого протокола, и платить её надо с открытыми глазами.
Вторая граница: в монитор строки шлёт человек, а постоянный партнёр платы — другой скетч или сервер. Схема приёма от этого не меняется, меняется только транспорт: та же строка уезжает по UART на вторую плату или в MQTT-топик, и конвейер приёма переезжает без правок. Целый менеджер задач на этом принципе собран в уроке про todo-менеджер на Serial — там команды длиннее, а разбор тот же самый. А мигание без delay, которое стоит в ветке blink, — обычный millis-таймер, вся механика в уроке про millis вместо delay.
| Формат команды | Что удобно | Где трещит |
|---|---|---|
| Префикс и число: led 1 | минимум байт, парсится на коленке за десять строк | каждый новый параметр — новая ветка и новый формат команды |
| CSV: led,1,300 | поля по позициям, канал дешёвый | запятая или кавычка в значении сдвигает все колонки |
| JSON-строка: {"cmd":"led","state":1} | ключи самонесущие, поля добавляются без смены формата | документ ест RAM: 768 байт против буфера в десятки байт |
| Двоичный пакет с длиной | самый дешёвый по каналу и быстрый в разборе | нечитаем в мониторе, отладка глазами недоступна |
- 1. Что вернёт readStringUntil(10), если отправить строку без перевода строки в конце?
- 2. Какая настройка монитора порта нужна, чтобы JSON-команда доезжала без секундных пауз?
- 3. Зачем перед чтением параметра вызывать doc.containsKey("state")?
- 4. В симуляторе deserializeJson вернул 0. Что это значит?
- 5. Сколько RAM на Uno съедает документ под команды из урока?
- Конвейер команды: available → readStringUntil(10) → deserializeJson → containsKey → ветка по cmd; один формат тянет любые новые поля
- readStringUntil копит байты до терминатора, а без него — до таймаута в 1000 мс: в мониторе включите перевод строки (NL)
- containsKey отличает отсутствующий ключ от честного нуля; промах по ключу — повод для дефолта и ответа со списком команд
- В движке симулятора deserializeJson даёт 1 на успехе; на плате канон — DeserializationError и проверка if (err)
- Цена самонесущих ключей — RAM: документ на 768 байт на Uno с 2 КБ SRAM; размер считайте через measureJson заранее
Частые вопросы по теме урока
- Что вернёт readStringUntil(10), если отправить строку без перевода строки в конце?
- Копилка байтов живёт до терминатора, а при его отсутствии — до таймаута, 1000 мс по умолчанию. Потом функция вернёт всё, что успела собрать: поэтому команда без NL доезжает с секундной задержкой. Накопленное — но только когда истечёт таймаут в 1000 мс
- Какая настройка монитора порта нужна, чтобы JSON-команда доезжала без секундных пауз?
- Терминатор — то, чего ждёт readStringUntil. Без перевода строки в конце отправки копилка высиживает таймаут на каждой команде; скорость порта и отметки времени на это не влияют. Перевод строки (NL) в конце отправки
- Зачем перед чтением параметра вызывать doc.containsKey("state")?
- Чтение по отсутствующему ключу молча даёт ноль, и команда led со state 0 неотличима от команды вообще без state. containsKey разворачивает эту пару случаев в разные ветки. Отличить отсутствующий ключ от честного нуля в значении
- В симуляторе deserializeJson вернул 0. Что это значит?
- В движке курса 1 — успех, 0 — строка не разобралась. На настоящей плате функция возвращает DeserializationError, где нулевой код Ok означает удачу, а проверка пишется как if (err). Строка не разобралась: JSON битый
- Сколько RAM на Uno съедает документ под команды из урока?
- DynamicJsonDocument doc(768) держит весь буфер в SRAM с момента создания. Это цена самонесущих ключей: буфер строки в десятки раз дешевле, но формат с ним не растёт. 768 байт из 2 КБ SRAM — заметная доля