Первую связку двух плат я собрал так: верандный узел меряет температуру и раз в минуту шлёт по проводу строку {"temp":18.5,"humi":64}, приёмник на второй UART должен её разобрать и включить грелку. В документации ArduinoJson я увидел, что deserializeJson принимает поток Stream, обрадовался и написал deserializeJson(doc, Serial) — одна строка вместо целого приёмника. Первые минуты всё выглядело хорошо. Потом грелка начала включаться через раз, а лог пополнился кодом IncompleteInput — пакет оборван, хотя провод целый и сосед шлёт стабильно.
Разгадка была во времени. При 9600 бод каждый символ стоит около миллисекунды: пакет из 24 символов едет 24 мс. Мой разбор стартовал в момент вызова — когда по проводу доехала только половина пакета, а дальше библиотека увидела пустоту на входе и честно ответила «данные кончились на полпути». Библиотека не виновата: она не знала, что после паузы приедет ещё байт. Не знал этого и я — до того вечера.
В этом уроке соберём приёмник правильно: копим символы, ждём маркер конца, разбираем только полный пакет. Разберёмся, когда deserializeJson из потока всё-таки легален, сколько байт буфера у аппаратного Serial на Uno, чем опасен delay в loop для пакетов и что делать, когда терминатора в канале нет. А в симуляторе приёмник оживёт по-настоящему: вы сами встанете на место соседнего узла и отправите пакет из монитора порта.
Собираем строку посимвольно
Правило приёма из двух частей: собирай пакет целиком, разбирай только полный. Часть первая — цикл while (Serial.available() > 0), который вынимает из порта всё, что успело приехать, по одному символу через Serial.read(): мы разбирали эти двое в уроке про available и read. Символ складывается в строку; строка копится, пока не придёт терминатор — символ конца пакета.
Терминатор нужен, потому что JSON сам по себе не сообщает «пакет кончился» до конца разбора, а парсеру нужен целый пакет. Для провода и монитора это '\n': клавиша Enter в мониторе порта шлёт его автоматически, соседний узел дополняет им свою строку. Второй нюанс — '\r': многие мониторы и старые модули шлют пару CR+LF, и лишний байт перед разбором лучше выкинуть; для JSON-парсера он безвреден — переживается как пробел, — но лог становится чище, а сравнения строк честнее.
Копить буду в String, и это осознанное исключение из моего же правила: в уроке про телеметрию я выступал против склейки в длинноживущих циклах. Здесь условия другие: строка короткая — 30–60 байт, приходит редко — раз в минуту, и после разбора она обнуляется, то есть память возвращается. На Uno это единичные перевыделения вместо сотен в секунду. Пуристам — char-буфер на 64–96 байт с индексом; механика та же, только line += c заменяется на buf[i++] = c и ручным терминатором.
Одна страховка, которой в этом цикле не хватает: у накопленной строки нет потолка. Если сосед завис и шлёт без терминатора — или терминаторы на канале съедаются, — line растёт, сколько бы её ни было. Лекарство на три строки: если line.length() перевалила за 120 — сбросить строку, увеличить счётчик потерь и напечатать предупреждение в лог. Сто раз я жалел, что не написал это сразу, и один раз — не жалел: зависший передатчик дал мне 40 килобайт мусора в String за ночь, из которых память на Uno выбралась только перезагрузкой.
#include <ArduinoJson.h> JsonDocument doc;String line = ""; void setup() { Serial.begin(9600);} void loop() { while (Serial.available() > 0) { char c = Serial.read(); if (c == '\n') { // Пакет собран: пустые строки пропускаем, остальное разбираем if (line.length() > 1) { DeserializationError err = deserializeJson(doc, line); if (err) { Serial.print("paket isporchen: "); Serial.println(err.c_str()); // имя кода: InvalidInput и т.д. } else { float t = doc["temp"] | -99.0; int h = doc["humi"] | 0; Serial.print("temp="); Serial.println(t); Serial.print("humi="); Serial.println(h); } } line = ""; // буфер готов к следующему пакету } else if (c != '\r') { line += c; // копим символы между терминаторами } } // здесь живёт остальной скетч: мигание, кнопки, датчики — приём им не мешает}Почему не deserializeJson(doc, Serial)
Чтение прямо из потока — легальный вызов, и для него есть честный сценарий: короткий пакет, надёжный канал и скетч, которому нечего делать, пока данные едут. Библиотека будет сидеть в чтении, пока не соберёт весь объект. Но если пакет оборвался — read() вернул -1 на пустом месте, — разбор ответит IncompleteInput, и повторять попытку вам придётся самому: библиотека уже выбросила то, что прочитала.
Вторая причина — блокировка. Пока deserializeJson сидит в потоке, ваш loop стоит: светодиод не мигает, кнопка не опрашивается, таймеры на millis копят долг. Приёмник из кода выше ведёт себя иначе: он вынимает по символу, сколько есть, и мгновенно возвращает управление — остальной скетч живёт между символами.
Третья причина — 64 байта. Столько держит буфер аппаратного Serial на Uno, и всё, что приехало в переполненный буфер, теряется молча. Пакет в 70 байт при loop с delay(100) гарантированно теряет хвост: скетч спит, порт копит, лишние байты выкидываются ещё до разбора. Мой приёмник опрашивает порт каждый проход loop — это доля микросекунды, — поэтому терять нечего. Цифры по буферу и грабли с переполнением мы считали в отдельном уроке. И последнее удобство, которое покупается сборкой строки: собранный пакет можно напечатать в лог перед разбором — отладка любит сырые данные, а не только вердикты.
С SoftwareSerial всё то же, только строже: программный UART читает байты в прерывании и не терпит соседей, которые держат прерывания выключенными — tone на другом пине или долгая печать в дисплей способны съесть пару байтов из середины пакета. Приёмник от этого не ломается: недостающие байты дадут razbor=0, и пакет уйдёт в ветку провала. Просто закладывайте переприём: узел повторит пакет через минуту — телеметрия прощает.
Маркер конца: не только Enter
Enter — терминатор по умолчанию, и для провода этого хватает: монитор шлёт его сам, соседний узел дополняет Serial.println(). Но на радиоканале и дешёвом Bluetooth символ-то может потеряться, а с ним — и весь пакет: строка копится без конца, разбор не случается. Выручает второй маркер — тишина: если после последнего принятого символа прошло 100 мс и строка не пуста, считаем пакет завершённым. Механика на millis занимает три строки: в момент приёма запоминаем время, в loop сравниваем с порогом. Цена решения — ограничение протокола: два пакета не могут идти с паузой меньше таймаута, иначе склейка примет их за один. Для телеметрии раз в минуту ограничение пустое, для чата между платами — уже существенное, поэтому выбирайте маркер под трафик.
Второй приём для грязных каналов — фильтр мусора на входе. Радиомодули при пробуждении выдают нулевые байты, переходники RS-485 порой сыплют шум до установления линии, а мой любимый HC-05 при переподключении присылал служебные строки со скобками. JSON обязан начинаться с фигурной скобки, поэтому первый непустой символ строки — дешёвая проверка, которая отсеивает половину мусора до deserializeJson: скобки нет — строка в мусорное ведро и счётчик, скобка есть — пакет на разбор. Экономия не в байтах, а в чистоте журнала: InvalidInput на каждое пробуждение радиомодуля забивает лог за сутки.
Когда пакетов много, канал шумный, а цена пакета высока — уместите в него целостность. JSON сам ничего не проверяет: два потерянных байта посередине превращают температуру 18.5 в 8.5 или делают строку непарсабельной — и то и другое молча. Поверх JSON поверх UART кладут то же, что и на любые бинарные протоколы: контрольную сумму пакета или байты чётности, о которых у нас есть отдельный разговор про протоколы с проверкой целостности. Мой личный порог: до десяти пакетов в минуту по проводу — чистый JSON с терминатором; дальше — сумма в хвосте пакета, и разбор только у пар, где сумма сошлась.
| Источник для deserializeJson | Когда беру | Цена и риск |
|---|---|---|
| Строка-литерал в скетче | тест разбора, демо, проверка библиотеки | вход виден целиком, но пакет не живой: канал не проверяется |
| String, собранный из Serial | реальный приём по проводу, Bluetooth, радио | нужен маркер конца; строка короткая и редкая — куча не страдает |
| Поток: deserializeJson(doc, Serial) | короткие пакеты, скетч без параллельных задач | блокирует loop на время приёма; обрыв — IncompleteInput |
- Оборвите пакет. Введите {"temp":25 без закрывающей скобки и нажмите Enter: razbor=0 и вежливая просьба повторить — на плате та же ветка напечатала бы имя кода через err.c_str().
- Два пакета подряд. Введите один JSON, Enter, второй JSON, Enter: каждый разберётся отдельно — терминатор делит поток на пакеты, перепутать их невозможно.
- Пробелы и порядок. Введите {"city":"Omsk" , "temp": 3} с лишними пробелами и другим порядком полей: разбор пройдёт — пробелы и порядок не значимы, как мы разбирали в уроке про синтаксис JSON.
- Слово без скобок. Введите temp=25: скетч попробует разобрать и откажет — фильтр по первой фигурной скобке отсеял бы такой мусор ещё до deserializeJson, добавьте его сами.
- JSON не сообщает о конце пакета сам: нужен маркер — '\n' для провода и монитора, таймаут тишины для радиоканала
- Правило приёма: Serial.available() и read() копят символы, deserializeJson зовётся только на полном пакете
- deserializeJson(doc, Serial) легален, но блокирует loop на приёме и отвечает IncompleteInput на оборванном пакете
- Буфер аппаратного Serial на Uno — 64 байта: delay в loop длиннее приёма теряет хвост пакета молча
- Собранная строка — подарок отладке: её видно в логе, по ней ведётся счётчик пакетов, её легко проверить глазами
- 1. Почему первый вариант с deserializeJson(doc, Serial) ловил IncompleteInput через раз?
- 2. Сосед уместил два пакета в один заход, терминаторов между ними не было. Что соберёт приёмник-склейка?
- 3. Что напечатает приёмник из симулятора на строку hello world?
- 4. Сколько байт держит буфер аппаратного Serial на Uno и чем это грозит длинному пакету?
- 5. Почему String-склейка посимвольно здесь оправдана, хотя в уроке про телеметрию я был против?
Домашнее задание с платой: свяжите две платы по Bluetooth HC-05 или напрямую UART-UART, поднимите приёмник из этого урока и отправьте с передатчика телеметрию из урока про сериализацию — получите полный цикл: датчик, документ, провод, документ, вердикт. Следующий урок — про то, когда сосед не ваша вторая плата, а сервер с погодой на другом конце интернета: там пакет приедет не посимвольно, а целым телом ответа, зато в три этажа вложенности — Погодный API: OpenWeatherMap.
Частые вопросы по теме урока
- Почему первый вариант с deserializeJson(doc, Serial) ловил IncompleteInput через раз?
- При 9600 бод символ едет около миллисекунды: разбор начинается в момент вызова, пустой read() на недоехавшем хвосте даёт IncompleteInput. Разбор стартовал раньше, чем пакет доехал целиком
- Сосед уместил два пакета в один заход, терминаторов между ними не было. Что соберёт приёмник-склейка?
- Без терминатора склейка видит одну длинную строку с двумя объектами — это невалидный JSON, razbor вернёт ноль. Маркер конца обязателен. Одну строку из двух пакетов, разбор сорвётся
- Что напечатает приёмник из симулятора на строку hello world?
- Разбор вернёт ноль, ветка провала напечатает подсказку. На плате там же печатается имя кода через err.c_str() — обычно InvalidInput. razbor=0 и просьбу повторить ввод
- Сколько байт держит буфер аппаратного Serial на Uno и чем это грозит длинному пакету?
- 64 байта — стандарт для аппаратного UART ATmega328. Пакет длиннее при тормозящем loop теряет хвост ещё до разбора. 64 байта: переполнение выбрасывает лишние байты молча
- Почему String-склейка посимвольно здесь оправдана, хотя в уроке про телеметрию я был против?
- Аргумент против склейки — сотни перевыделений в секунду в долгом цикле. Здесь: 30–60 байт раз в минуту и обнуление — единичные выделения. Строка короткая, приходит редко и обнуляется после разбора