Тепличный узел слал показания на сервер методом GET: /api/save?device=teplica1&temp=21.4&humi=58. Работало, пока я не заглянул в логи сервера и не увидел там каждый ключ в открытом виде — вместе с токеном доступа, который пришлось передавать тем же способом. А когда узлов стало три и к телеметрии добавился прогноз, URL полез за пределы, которые сервер согласен принимать: на строку запроса у HTTP есть лимит, и часть серверов режет её ещё раньше — четырьмя сотнями с формулировкой про слишком длинный URI.
Переезд на POST оказался не про «ещё один метод», а про разделение труда: данные уезжают в тело, заголовки остаются заголовками, и токен перестаёт светиться в логах. Анатомию POST-запроса мы построчно разбирали в уроке про телеметрию на ESP32 — там же про то, почему повторная отправка создаёт вторую запись, а не обновляет первую. Здесь берём готовый каркас и закрываем главную тему: как собрать тело не руками, а через ArduinoJson, чтобы сервер принял его с первого раза.
Спойлер из моей практики: тело бывает правильное, а сервер всё равно против — если заголовок Content-Type говорит text/plain, серверный JSON-парсер отказывается работать ещё до чтения данных. Разберём это место по косточкам, посчитаем Content-Length заранее и разберём ответ сервера, который приходит в том же формате JSON.
Анатомия запроса с телом: кто что считает
POST-запрос — это те же строки, что и GET: метод, путь, версия, заголовки. Отличие в конце: после заголовков идёт пустая строка, а дальше — тело произвольной длины. За тело отвечают два заголовка: Content-Type говорит серверу, как читать содержимое, Content-Length — сколько байтов читать. Пропустите Length — сервер либо недочитает, либо будет ждать дальше; наврите — отрежет лишнее. В GET-запросе обоих заголовков не было: там тело пустое, и весь разговор шел в адресной строке — теперь адрес останется коротким, а данные переезжают внутрь.
Хорошая новость: руками считать ничего не надо. HTTPClient, с которым мы работали в уроке про GET из скетча, сам посчитает длину переданной строки и вставит заголовок — http.POST(body) возвращает код ответа, и каркас запроса собран библиотекой. Единственное, что остаётся на вашей совести, — Content-Type: по умолчанию HTTPClient пишет text/plain, и серверу, ждущему JSON, это не подходит. Добавьте http.addHeader("Content-Type", "application/json") до вызова POST — и серверный парсер увидит честное предупреждение о содержимом.
А само тело собирает ArduinoJson — ровно та механика из урока про сборку своего JSON: doc["temp"] = t; и serializeJson(doc, body). Библиотека ставит кавычки, запятые и экранирование сама — то, на чём ломалась ручная склейка. Приятный бонус: длину тела можно узнать до отправки через measureJson(doc) — годится для лога, для проверки влезания в буфер и для того, чтобы понимать, сколько байтов уходит в сеть за каждую отправку. Разница между GET и POST здесь не в JSON, а в упаковке: тот же документ на GET поехал бы параметрами в URL и потерял бы все заголовки над телом.
Есть и вторая сторона обмена: сервер на POST отвечает. Код 200 или 201 — запись принята; в теле ответа часто лежит JSON со статусом и номером записи: {"status":"ok","id":1241}. Этот ответ разбирается тем же deserializeJson во второй документ — я завожу отдельный doc поменьше, чтобы телеметрия и ответ не путались в одной куче. Ответ с кодом 4xx — повод напечатать тело ответа целиком: серверы нередко пишут туда человеческое объяснение, какое поле им не понравилось.
#include <WiFi.h>#include <HTTPClient.h>#include <ArduinoJson.h> const char* WIFI_SSID = "HomeWiFi";const char* WIFI_PASS = "parol";String serverUrl = "http://192.168.1.50:8000/api/telemetry"; JsonDocument doc; // тело запросаJsonDocument resp; // ответ сервера — отдельный документ unsigned long lastSend = 0;const unsigned long PERIOD = 60000UL; // раз в минуту void sendTelemetry() { float t = 21.4; // здесь чтение вашего датчика int h = 58; doc.clear(); // v7 очищает при deserializeJson, но при ручной // сборке clear нужен вам сам — иначе старые поля // уедут на сервер вместе с новыми doc["device"] = "teplica1"; doc["temp"] = t; doc["humi"] = h; String body = ""; serializeJson(doc, body); // тело одним вызовом, слеши на библиотеке Serial.print("telo ("); Serial.print(measureJson(doc)); Serial.println(" bait):"); Serial.println(body); HTTPClient http; http.begin(serverUrl); http.addHeader("Content-Type", "application/json"); // ДО вызова POST int code = http.POST(body); // Content-Length библиотека посчитает сама http.end(); Serial.print("kod="); Serial.println(code); // 200/201 — принято; 400/415 — Content-Type или битое тело; 401 — токен if (code == 200) { // разбор ответа на плате: deserializeJson(resp, http.getString()) // до http.end() — строка живёт до закрытия соединения Serial.println("zapis prinyata"); }} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print("."); }} void loop() { if (millis() - lastSend >= PERIOD) { lastSend = millis(); sendTelemetry(); }}| Content-Type в запросе | Что скажет сервер с JSON-схемой |
|---|---|
| application/json | 201: тело разобрано, запись создана |
| text/plain — по умолчанию HTTPClient | 400 или 415: парсер откажет ещё до чтения данных |
| x-www-form-urlencoded | 400: поля прочтутся как строки-пары, а не JSON |
| заголовка нет вовсе | зависит от сервера: от отказа до молчаливого «принял, но не разобрал» |
Код ответа — единственное, что POST возвращает без всякого разбора, и по нему видно, на каком этапе всё сломалось. 200 и 201 — запись принята, разница между ними вкусовая: 201 значит «создал». 400 — тело не понравилось парсеру; 415 — не понравился Content-Type; 401 — токен; 429 — вы шлёте чаще, чем сервер готов принимать. Минус один у HTTPClient — соединение вообще не установилось: сервер выключен, порт закрыт, WiFi упал между begin и POST. Коды HTTP и коды разбора из урока про deserializeJson живут на разных этажах и проверяются в разном порядке: сначала число от http.POST, потом DeserializationError от тела ответа.
Журнал отправки стоит двух строк и окупается за первую неделю: kod=, measureJson(doc) и метка millis()/1000. Размер тела телеметрии из четырёх полей — 50-70 байт; если замер показывает сотни, вы тащите старые поля без clear() или строки длиннее, чем думали. Для буферного варианта — собрать JSON в заранее выделенный char-массив вместо String — приёмы и запас в тридцать процентов разобраны в уроке про serializeJson и приёмники; для POST на минутных интервалах String тоже живёт, но буфер я ставлю всегда, когда узел уходит на дачу без присмотра.
И про повторы: POST не идемпотентен — повторная отправка создаёт вторую запись, а не обновляет первую. Лечение — метка времени внутри тела: сервер отличит повтор от новых данных. На минутной телеметрии это теория, на платежах — практика; правило простое: решение «отправить ещё раз» принимает ваш скетч по коду ответа, а не библиотека по своему настроению.
Стенд сборки: тело на глазах у монитора
Отправку http.POST движок не эмулирует — эту половину вы видели в code-блоке выше и на живой плате. А вот сборка тела — та половина, на которой ломаются скетчи, — в симуляторе настоящая, и я ставлю её на стенд: введите в монитор порта число, и оно уйдёт в документ, скетч напечатает готовое тело целиком и его длину для Content-Length. Поменяйте число — длина пересчитается: «23.7» и «-5.5» занимают поровну, а добавленный ключ press вырастит и тело, и заголовок. Собранный документ печатается той же serializeJson, что в логе и на плате — разница только в приёмнике, и это не совпадение, а тот самый принцип «одна сборка — много адресатов».
Ответ сервера на стенде — заглушка {"status":"ok","id":1241}, на плате её место займёт http.getString(). Но разбор — настоящий, во второй документ resp, и поля читаются точечной формой: resp.status, resp.id — движение симулятора, равнозначное скобкам на плате. Такой стенд — половина отладки интеграции с чужим сервером: вы заранее видите ровно ту строку, которая уйдёт в сеть, и можете сверить её с документацией API глазами. Команды в монитор порта мы уже вводили в уроке про команды по Serial — механика та же, только команда здесь превращается в тело запроса. А весовой разбор ответа сервера по WiFi с проверкой кода — в уроке про JSON по HTTP на ESP32: там же про двойную память getString и чтение из потока.
- Минус в теле. Введите -5.5: минус уезжает в JSON как обычный символ, тело и длина остаются честными — с этим мы мучились в самодельном разборе из первого урока раздела.
- Новое поле. Добавьте строку doc["press"] = 748; рядом с humi и введите значение: content_length вырастет на длину пары — каждое поле стоит и тела, и заголовка.
- Два знака. Введите 21 и 21.5 и сравните тела: целое печатается без точки, дробное — с ней; сервер видит два разных числа, а не «21 и что-то».
- На настоящей плате. Перенесите code-блок на ESP32 с домашним сервером или Node-RED: ответ сервера придёт через http.getString() и разберётся тем же resp-документом.
- POST переносит данные из URL в тело: токен перестаёт светиться в логах, длинные пакеты перестают упираться в лимит строки запроса
- HTTPClient сам считает Content-Length по переданной строке; Content-Type по умолчанию text/plain — addHeader("Content-Type", "application/json") до POST обязателен
- Тело собирается через doc["ключ"] = значение и serializeJson(doc, body) — пунктуация и экранирование на библиотеке
- measureJson(doc) даёт длину тела до отправки: лог, проверка буфера, понимание цены каждой отправки
- При ручной сборке тела повторно — doc.clear() перед заполнением, иначе старые поля уедут на сервер вместе с новыми
- Ответ сервера на POST — тоже JSON: разбирайте его во второй документ, а при коде 4xx печатайте тело ответа целиком
- 1. Тело запроса собрано верно, сервер отвечает 400. С чего начать проверку?
- 2. Кто считает Content-Length при http.POST(body)?
- 3. Зачем нужен measureJson(doc), если POST и так отправит тело?
- 4. Второй узел заполняет тот же doc без clear(): в теле оказались поля прошлого узла. Почему?
- 5. Сервер вернул код 200 и тело {"status":"ok","id":1241}. Как правильно достать номер записи?
Три проекта подряд прошли один маршрут с разных сторон: чтение чужого API на дисплей, чтение словаря котировок и сборка своего тела для чужого сервера. Дальше по разделу — материалы, где документ становится крупнее: фильтры, массивы, вложенность в полном объёме и переезд с Uno, когда 2 КБ перестают хватать. А если ваши узлы разрослись до десятков сообщений в минуту, самый короткий путь дальше — MQTT: собранный здесь JSON публикуется в топик одной строкой, и сервер узнаёт о телеметрии без единого POST.
Частые вопросы по теме урока
- Тело запроса собрано верно, сервер отвечает 400. С чего начать проверку?
- HTTPClient по умолчанию шлёт text/plain. Сервер с JSON-схемой отказывается парсить такое тело ещё до чтения данных. addHeader с application/json ставится до вызова POST. С заголовка Content-Type: text/plain вместо application/json серверный JSON-парсер не устраивает
- Кто считает Content-Length при http.POST(body)?
- HTTPClient считает длину строки и вставляет заголовок сам. measureJson полезен для лога и проверки буфера, но для отправки он не обязателен. Библиотека HTTPClient сама, по длине переданной строки
- Зачем нужен measureJson(doc), если POST и так отправит тело?
- Замер до отправки показывает, сколько байтов уходит в сеть за раз, и ловит невлезание в буфер заранее, а не по ответу сервера. Он даёт длину тела до отправки — для лога, проверки буфера и цены трафика
- Второй узел заполняет тот же doc без clear(): в теле оказались поля прошлого узла. Почему?
- Документ живёт, пока живёт переменная, и хранит всё записанное. При повторной сборке другого пакета нужен doc.clear() — или разбор, который в v7 очищает документ сам. Документ хранит записанные ключи до явной очистки — повторная сборка дописывает, а не заменяет
- Сервер вернул код 200 и тело {"status":"ok","id":1241}. Как правильно достать номер записи?
- Ответ на POST — обычный JSON: отдельный документ, deserializeJson, чтение по ключу. Код HTTP сообщает лишь о статусе операции, номер записи живёт в теле. Разобрать ответ deserializeJson во второй документ и прочитать resp["id"]