Прогноз для теплицы приходил от узла на веранде: на каждый день две температуры, ночная и дневная. Первая версия протокола была честной и наивной — четырнадцать ключей: day1, night1, day2, night2 и так до седьмого дня. Скетч вытаскивал их четырнадцатью строками чтения, и всё работало ровно до того дня, как я захотел десятидневный прогноз. Ключи в пакете добавились, скетч — нет: пять новых дней молча остались нулями, а теплица не открыла форточку, потому что по таблице «днём минус».
Вторую версию я сгоряча упростил: сложил все значения в один плоский массив и различал день и ночь по чётности индекса. Работало и пересчитывалось. Через месяц я открыл тот скетч и добрых полчаса вспоминал, что именно лежит под нечётными номерами. Экономия одной пары скобок обернулась легендой, которую приходилось носить в голове и передавать из рук в руки.
В этом уроке — третья версия, массив массивов: внешний список по дням, внутренний кортеж из двух значений. Разберём чтение по индексам, порядок циклов, где двумерные данные живут в настоящих API и сколько стоит каждый уровень вложенности. Прогоним всё в симуляторе: движок книги читает вложенные массивы так же, как настоящая плата.
Матрёшка: внешний список, внутренний кортеж
Массив внутри массива записывается двумя парами квадратных скобок: [[10.2,18.5],[9.8,17.1],[11.0,19.4]]. Внешний массив держит по элементу на день, каждый элемент — маленький массив из двух значений: ночной и дневной градус. Вся грамматика уже была в уроке про синтаксис JSON, ничего нового: скобки те же, просто внутри значений лежат ещё одни скобки. Вкладывать можно и дальше, но каждый уровень — отдельный контейнер в памяти документа со своей ячейкой и своим разбором, и на Uno с двумя килобайтами SRAM моё правило — не глубже двух-трёх этажей.
Чтение идёт слева направо, по одному индексу за шаг: doc["week"][2][0] — ключ week, потом третий день, потом первое значение внутри него. Индексы считаются с нуля, как при чтении плоских полей. Порядок скобок — это порядок спуска: поменять местами два последних индекса в кортеже из двух значений значит выйти за границу, и библиотека молча вернёт ноль. Никакого сообщения — ноль как температура, ровно как в моей теплице того июля.
Циклы ложатся на матрёшку как по чертежу: внешний for идёт по дням, внутри него два чтения — [d][0] и [d][1]. Если значений в кортеже больше, внутренний уровень тоже перебирается своим for с собственной границей. Главное — не смешивать уровни: когда оба цикла идут по одному массиву, легко уехать на строку выше и читать чужие значения, не получив ни единого предупреждения.
Научитесь читать матрёшку глазами парсера — это экономит часы. Скопируйте пакет и пройдите по скобкам: открылась квадратная — массив, запятые делят его на дни; открылась вложенная квадратная — кортеж, в нём два значения. Глубина видна сразу, имена не нужны. Если структура чужая, прогоните её через любой онлайн-форматировщик: он разложит плотную строку на уровни с отступами, и каждый этаж получит свою строку. Мне хватает этого ритуала перед каждым новым API: минута взгляда против получаса проб и нулей на экране.
Двумерных данных вокруг много. Прогноз OpenWeatherMap — массив list из шагов по три часа, и в каждом шаге спрятан объект main с температурой; расписание полива — дни недели, в каждом часы и литры; таблица кодов пульта — ряды и колонки. Когда структура двумерная по смыслу, честнее всего отдавать её двумерной и в пакете, а не сплющивать в строку с легендой на полях.
#include <ArduinoJson.h> // Неделя теплицы: внешний массив — дни, внутренний — [ночь, день]JsonDocument doc; // v7; в v6 — StaticJsonDocument<384> void setup() { Serial.begin(9600); String packet = "{\"week\":[[10.2,18.5],[9.8,17.1],[11.0,19.4],[8.6,16.2],[12.1,21.0]]}"; DeserializationError err = deserializeJson(doc, packet); if (err) { Serial.print("Razbor sorvan: "); Serial.println(err.c_str()); return; } float sumDay = 0; for (int d = 0; d < 5; d++) { float night = doc["week"][d][0]; float day = doc["week"][d][1]; sumDay = sumDay + day; Serial.print("Den "); Serial.print(d); Serial.print(" noch="); Serial.print(night, 1); Serial.print(" den="); Serial.println(day, 1); } Serial.print("Srednyaya dnem: "); Serial.println(sumDay / 5, 2); // Настоящий API даст матрёшку с объектами внутри массива — // прогноз OpenWeatherMap: список шагов по три часа: // float t0 = doc["list"][0]["main"]["temp"]; // Скобки те же, чтение тоже слева направо.} void loop() {}Массив объектов против массива массивов
У двумерной таблицы есть сосед — массив объектов: [{"noch":10.2,"den":18.5},{"noch":9.8,"den":17.1}]. Он читается глазами без легенды: сразу видно, какое значение ночное. Плата за это — байты: каждое значение тащит имя ключа в каждом элементе, и на пакете из сотни строк разница с массивом массивов доходит до сотен байтов. Для протоколов между моими собственными устройствами я беру массив массивов: короче, а порядок полей я сам же зафиксировал и подписал в скетче.
Цена вопроса в байтах считается на пальцах. Пара с именами {"noch":10.2,"den":18.5} занимает двадцать четыре символа; та же пара массивом [10.2,18.5] — двенадцать. На неделе из семи дней разница — восемьдесят четыре байта, на сотне строк — больше килобайта, а для Uno это не «просто байты», а половина всей SRAM. Имена ключей — самый дорогой компонент пакета: каждое значение несёт своё имя рядом с собой через запятую.
Для чужих API выбора нет: приходят объекты, и путь достаётся длиннее — doc["list"][0]["main"]["temp"]. Такой спуск мы уже делали на живой плате в уроке про JSON по HTTP на ESP32: список шагов с вложенным объектом разбирается той же логикой слева направо, просто скобок больше. Уровни те же, меняется только тип каждого этажа.
Есть и третий путь — плоские ключи day1, night1: для двух-трёх пар он даже читабельнее, но при семи днях это четырнадцать строк чтения и четырнадцать мест для опечатки. Когда данных по проводу много и их хочется печатать колонками, посмотрите таблицы данных из Serial: двумерная структура и табличный вывод созданы друг для друга.
| Способ разложить таблицу | Чтение | Когда беру |
|---|---|---|
| Массив массивов | doc["week"][d][0] | Свои протоколы: короче всех, порядок полей зафиксирован вами |
| Массив объектов | doc["list"][d]["den"] | Чужие API и пакеты, которые будут читать глазами другие люди |
| Плоские ключи | doc.day1, doc.night1 | Две-три пары значений; дальше код размножается |
| Один массив с чётностью | doc.t[i], день по чётности | Не беру: легенда живёт в голове и умирает первой |
Матрёшка в симуляторе
Движок книги читает вложенные массивы по-настоящему: строка разбирается в документ, элементы доступны по двойному индексу, цикл по дням работает как на плате. Одна честная оговорка про форму доступа: в симуляторе ключ читается точечной записью doc.week[d][0] — на настоящей плате она равнозначна doc["week"][d][0], но движок понимает только точку. Скобочную форму, вложенные объекты и полный тип DeserializationError гоняйте на плате — скетч из блока выше для того и лежит.
Ещё одно свойство, о котором забывают: внутренние массивы не обязаны быть одинаковой длины. В неделе из кортежей по два значения это неважно, но пакет вида [[10,18],[9,17,4]] — кортеж из трёх значений во втором дне — валиден, и чтение третьего элемента честно вернёт четвёрку. Полагаться на переменную длину я не советую: в своём протоколе держите кортеж фиксированным, а в чужих пакетах проверяйте длину перед чтением хвоста — чтение за границей и здесь молча даст ноль.
Внутри сим-блока — неделя теплицы: семь пар «ночь, день». Строка razbor= показывает, удался ли разбор; дальше цикл печатает каждую строку таблицы с разницей градусов и выводит среднюю по дням и самый холодный день. Поменяйте одно значение в пакете — пересчитается вся таблица: разбор, обход, статистика. Чтение за границей массива движок, как и библиотека, ответит нулём — проверьте это экспериментом из списка ниже.
- Добавить день. Впишите восьмую пару [9.4,16.8] в пакет и поменяйте границу цикла с 7 на 8: средняя и минимум пересчитаются сами.
- Перепутать индексы. Поменяйте местами [d][0] и [d][1] в чтениях: ночь и день поменяются ролями, разница станет отрицательной — парсер даже не намекнёт.
- Съехать за границу. Добавьте строку с чтением doc.week[7][0] при семи днях: вернётся ноль — так же молча ведёт себя библиотека на плате.
- На настоящей плате. Перенесите скетч из code-блока на Uno, вставьте чтение doc["week"][0][2] и сравните с симулятором: ноль на невыделенной позиции одинаков везде.
- Массив массивов — внешний список по строкам, внутренний по значениям; чтение идёт по одному индексу за шаг: doc["week"][2][0]
- Циклы ложатся на структуру как по чертежу: внешний for по дням, внутри — чтения [d][0] и [d][1]
- Массив объектов читается глазами легче, но тянет имена ключей в каждом элементе; для своих протоколов я беру массив массивов
- Каждый уровень вложенности — отдельный контейнер в памяти документа; на Uno держите глубину два-три этажа
- Чтение за границей массива молча даёт ноль — и в библиотеке на плате, и в движке симулятора
- 1. В пакете {"week":[[10.2,18.5],[9.8,17.1]]}: что вернёт чтение doc["week"][1][0]?
- 2. Семь дней, в каждом по два значения. Сколько чисел достанет цикл из двух чтений doc["week"][d][0] и doc["week"][d][1]?
- 3. Почему для своих протоколов я выбираю массив массивов, а не плоский массив с чётностью индекса?
- 4. Что вернёт чтение doc["week"][9][0], если в массиве пять дней?
- 5. Какой глубины вложенности я держу потолок на Uno с её двумя килобайтами SRAM?
Две пары скобок, два цикла, ноль магии — двумерные данные перестают пугать, как только вы читаете их глазами парсера: индекс за индексом, слева направо. Числа из симуляции и с платы совпадут до сотой: средняя по дням 18.51 градуса одна и та же по обе стороны экрана. Следующий шаг — данные, которых много: история измерений, которую терять по одной точке за раз жалко и каналу, и серверу. В следующем уроке пакуем сотню показаний в один JSON, считаем его байты до отправки и рисуем график прямо в мониторе порта. Домашнее задание с платой: примите свой же недельный пакет обратно и разложите по дням, сверив с печатью из этого урока; а если история длиннее недели, ей самое место в логе на SD-карте.
Частые вопросы по теме урока
- В пакете {"week":[[10.2,18.5],[9.8,17.1]]}: что вернёт чтение doc["week"][1][0]?
- Индексы считаются с нуля: [1] — второй день, то есть пара [9.8,17.1]; второй индекс 0 — ночное значение. 10.2 сидит в doc["week"][0][0]. Спуск всегда идёт слева направо, скобка за скобкой. 9.8
- Семь дней, в каждом по два значения. Сколько чисел достанет цикл из двух чтений doc["week"][d][0] и doc["week"][d][1]?
- Семь дней по два значения — четырнадцать чтений. Плоский массив из четырнадцати чисел дал бы то же самое, но без именованной структуры: день и ночь различались бы только позицией. 14
- Почему для своих протоколов я выбираю массив массивов, а не плоский массив с чётностью индекса?
- Чётность индекса — легенда, которая живёт в памяти автора и через месяц стоит часа раскопок. Две скобки в пакете дешевле этой памяти. Легенда читается прямо из структуры, а не из головы автора
- Что вернёт чтение doc["week"][9][0], если в массиве пять дней?
- Выход за границу массива библиотека переживает молча: чтение даёт ноль. Никакого сообщения в Serial — ноль неотличим от честного измерения. Проверяйте границы, если длина пакета может меняться. Вернёт ноль молча
- Какой глубины вложенности я держу потолок на Uno с её двумя килобайтами SRAM?
- Каждый уровень — отдельный контейнер в памяти документа. На Uno разумный потолок — два-три этажа, дальше — плоские ключи или плата побольше. Два-три уровня