Узел климата в мастерской я долго отлаживал в каше: в одном потоке порта ехала JSON-строка телеметрии, следом реплики отладки, следом мои попытки рисовать график руками. Serial Plotter в Arduino IDE 2 честно пытался строить кривые по всему, что видел, и превращал каждую фигурную скобку в мусорный ряд. Канал при этом один, а потребителей двое: график нужен каждые две секунды, журнал — каждый пакет целиком и без потерь.
Формат плоттера мы разбирали в уроке про Serial Plotter: пары метка=значение закрывает println, проза среди чисел ряды ломает — JSON с его скобками и кавычками именно такая проза. Здесь угол другой: документ один, а кранов из него два — пары для плоттера и JSON-строка для журнала, и оба крана открываются из одного места скетча, без второй сборки значений.
План: почему JSON не доезжает до плоттера; документ как единственный источник чисел; второй кран в файл на SD через Print-интерфейс; симулятор с переключателем журнала кнопкой — и счётчик, который показывает, сколько байтов канала стоит каждый формат.
Один документ, два крана
Документ собирается один раз за такт: up, t, lux — присваиваниями, как в уроке про телеметрию трёх датчиков. Дальше два потребителя. Плоттеру уходят пары через Serial.print по кускам: t=24 lux=51 up=120, кадр закрывает один println — без него кривые стоят, эта грабля уже разбиралась в уроке про плоттер. Журналу — та же телеметрия одной строкой: serializeJson(doc, приёмник), формат один в один с JSON Lines-логом: одна строка — одна запись, приёмник режет поток по переводам строки.
Выгода не в байтах, а в честности чисел: плоттер и журнал не могут разъехаться, потому что читают один документ в один такт. Поймали скачок на графике — открываете журнал и видите пакет целиком, с отметкой up. Любая вторая сборка значений из переменных мимо документа — та типовая щель, где график показывает одно, а лог записывает другое.
#include <ArduinoJson.h>#include <SD.h> DynamicJsonDocument doc(256); // v7: JsonDocument doc; unsigned long last = 0;const unsigned long PERIOD = 60000;File zhurnal; void setup() { Serial.begin(9600); SD.begin(10); // CS карты на D10} void loop() { if (millis() - last < PERIOD) return; last = millis(); doc.clear(); doc.up = millis() / 1000; doc.t = map(analogRead(A2), 0, 1023, 15, 35); doc.lux = map(analogRead(A1), 0, 1023, 0, 100); // Канал 1: плоттер — только пары, кадр закрывает println Serial.print("t="); Serial.print(doc.t); Serial.print(" lux="); Serial.print(doc.lux); Serial.println(); // Канал 2: журнал — JSON-строка в файл через Print-интерфейс zhurnal = SD.open("log.jsonl", FILE_WRITE); if (zhurnal) { serializeJson(doc, zhurnal); // File — такой же Print, как Serial zhurnal.println(); zhurnal.close(); // close сбрасывает буфер на карту }}Файл на карте — тоже приёмник печати: serializeJson(doc, zurnal) работает с любым наследником Print, и File — наследник. Строку закрывает println, файл — close: без него буфер не добежит до карты, и хвост суток осядет в никуда. Журнал за сутки почасово — около двух десятков килобайтов, карта даже малого объёма принимает такие журналы годами; узкое место здесь не объём, а скорость записи, поэтому close после каждой строки я не пропускаю ни разу. Базовую сериализацию в String и в Serial мы проходили в уроке про serialize — здесь меняется только приёмник.
На Uno с её двумя килобайтами ОЗУ документ на 256 байт и хозяйство SD живут вместе спокойно: бюджет пары мы уже считали в уроке про два документа, здесь вторым потребителем служит файл, и место он берёт из того же кармана. Если плат две, журнал удобно уводить на вторую через UART — там он попадает в тот же разбор deserializeJson, и второй узел становится архивом без собственной памяти.
Что куда едет
Счётчик bayt_json делает разницу наглядной: строка телеметрии с тремя полями стоит около шестидесяти байтов, кадр пар для плоттера — около двадцати. При 9600 бод и такте в две секунды это незаметно; на периоде в сто миллисекунд журнал уже съедает треть канала, и кривые начинают дохнуть на глазах — порт один, скорости потребителей складываются. Реплика между кадрами пар — та же цена: одна строка прозы превращает соседние числа в безымянные позиционные ряды, порядок которых скачет от кадра к кадру.
Поэтому правило у меня строгое: в Serial для плоттера не едет ничего, кроме пар и перевода строки; реплики отладки — редкими пачками или за флагом сборки; журнал — в файл, на сервер или раз в N пакетов, когда захотелось глянуть. Переключатель журнала кнопкой, как в симуляторе, на плате заменяет флаг компиляции: отладочная сборка болтает, релизная молчит и только пишет — такой разбор разобран в уроке про dbg-макросы.
В симуляторе плоттер и монитор — вкладки одного Serial: кадр пар рисует три ряда с подписями, JSON-строка после нажатия кнопки падает в монитор целиком, и bayt_json показывает её цену против кадра пар. Кривые при включённом журнале не портятся: в симуляторе плоттер слеп к скобкам и молча их пропускает. В настоящей IDE 2 та же строка нарисует мусорные ряды — поэтому на плате второй кран ведёт в файл или на сервер, а не в общий порт.
Разбор скетча: документ собирается присваиваниями и читается обратно точечной формой doc.t и doc.lux — пары уходят в плоттер из документа, а не из переменных, вот где документ работает единственным источником. clear() перед сборкой держит состав ключей постоянным: кадр пар выходит одной длины от такта к такту, и кривые едут ровно, без ступенек от выросшего числа.
У плоттера есть и собственный потолок: в каждом ряду живёт около двухсот сорока последних точек — при такте в две секунды это восемь минут истории, дальше старое выталкивается свежими кадрами. Для разового осмотра хватит с головой, а длинную историю плоттер всё равно не хранит — вот зачем нужен второй кран: журнал помнит всё, что успел записать, и помнит пакетами, а не точками.
Период выбирается от обратного: посчитайте байты обоих кранов за минуту и оставьте каналу запас вдвое. Кадр пар каждые две секунды плюс журнал раз в минуту — это около семисот байтов в минуту против шести сотен байтов в секунду при 9600 бод: запас почти в пятьдесят раз, и он нужен не среднему, а пику — реплике отладки, команде, ретраю. На приёме сидит буфер на 64 байта, его устройство мы разбирали в уроке про буфер UART: если скетч ушёл в запись карты на полминуты, хвосты кадров пар потеряются, и кривые встанут, хотя датчики живы.
| Поток | Цена | Куда и зачем |
|---|---|---|
| Пары label=value в Serial | около 20 байтов кадр | плоттер: три ряда с подписями каждые 2 с |
| JSON-строка в журнал | около 60 байтов пакет | сервер, SD-файл, пост-разбор: пакет целиком |
| JSON-строка в плоттер | те же байты, ноль кривых | мусорные ряды — так не делать |
| Реплики отладки между кадрами | десятки байтов, редко | за флагом DEBUG или раз в минуты |
- 1. Почему строку {"t":21.4,"lux":77} не стоит кормить Serial Plotter?
- 2. Кадр для плоттера печатается четырьмя Serial.print и одним println в конце. Что именно закрывает кадр?
- 3. Журнал пишется на SD-карту. Что делает serializeJson(doc, zhurnal), где zhurnal — открытый File?
- 4. bayt_json=60 у JSON-строки против 20 байтов кадра пар. Куда едет длинная строка без ущерба для графика?
- 5. Счётчик bayt_json у каждого пакета захламил канал. Что делаете?
- Один документ за такт — два крана: пары label=value в плоттер, JSON-строка в журнал; вторая сборка значений мимо документа — источник расхождений
- Плоттер ест пары метка=значение и голые числа: скобки и кавычки JSON для него проза — мусорные ряды
- serializeJson(doc, приёмник) печатает в любой наследник Print: Serial, String, File на SD — и строку закрывает println, файл close
- bayt_json против кадра пар: около 60 против 20 байтов — на быстром периоде журнал съедает канал
- Флаг DEBUG и выборочные реплики держат Serial чистым: график не должен зависеть от журнала
Частые вопросы по теме урока
- Почему строку {"t":21.4,"lux":77} не стоит кормить Serial Plotter?
- Формат плоттера аскетичен: пары метка=значение или голые числа. Скобки и кавычки JSON он читает как прозу — и строки с ней превращаются в безымянные ряды, которые скачут от кадра к кадру. Скобки и кавычки для него проза среди чисел — ряды ломаются
- Кадр для плоттера печатается четырьмя Serial.print и одним println в конце. Что именно закрывает кадр?
- Пока перевода строки нет, кадр считается незакрытым и кривые стоят. Serial.print собирает кадр по кускам, один println в конце — команда рисовать; это уже разбиралось в уроке про плоттер. println: плоттер кадрирует по переводу строки
- Журнал пишется на SD-карту. Что делает serializeJson(doc, zhurnal), где zhurnal — открытый File?
- Второй аргумент serializeJson — любой Print-приёмник: Serial, String, File. Строку закрывает println, файл — close: без него буфер не сбежит на карту и хвост журнала осядет в никуда. Печатает документ в любой наследник Print, и File — такой наследник
- bayt_json=60 у JSON-строки против 20 байтов кадра пар. Куда едет длинная строка без ущерба для графика?
- Порт один, скорости складываются: на периоде в сто миллисекунд журнал съедает треть канала и кривые дохнут. Журнал уводят в File на SD или на сервер — тогда Serial остаётся чистым для пар. В файл или на сервер: второй кран, мимо Serial
- Счётчик bayt_json у каждого пакета захламил канал. Что делаете?
- Счётчик — инструмент, а не поток: на быстрых периодах его печатают выборочно, при старте и после правок сборки. Полный контрольный посыл с печатью — одна строка после каждой правки, не в каждом такте. Печатаю счётчик один раз из двадцати пакетов и после смены состава полей