Добрый день, уважаемый читатель! Сегодня поговорим о том, как выполнять различные HTTP-запросы из устройств на базе:
- ESP8266 или ESP32 под управлением фреймворка Arduino
- ESP32 под управлением фреймворка ESP-IDF
В данной статье рассмотрим самый простой вариант – без шифрования, а в следующей части марлезонского балета поговорим как прикрутить ко всему этому TLS-шифрование. Тема, в общем-то не очень сложная – можете считать данную статью как всего-лишь предисловием к статье о TLS-шифровании.
Для чего нужны HTTP-запросы в вашем устройстве? С помощью HTTP устройства могут получать какие-либо данные с различных web-серверов либо “общаться” с различными внешними сервисами посредством API (Application Program Interface – программный интерфейс для приложений). Ну например:
- Отправка уведомлений в Telegram или управление устройством через Telegram Bot API
- Отправка данных на Open Monitoring, Thing Speak, Народный мониторинг и прочие сервисы для накопления данных
- Взаимодействие с различными облачными системами IoT через API интерфейсы серверов.
В общем спектр применений HTTP-сообщений в системах с зачатками разума довольно широк. Я не буду сильно углубляться в теорию, об этом много и подробно написано в этих ваших интернетах, но некоторые аспекты осветить надо, чтобы в дальнейшем было понятно, что и зачем.
Немножко теории
HTTP сообщения – это протокол обмена данными между клиентом и сервером в текстовом виде. Есть два типа сообщений:
- запросы (HTTP requests) — сообщения, которые отправляются клиентом на сервер, чтобы вызвать выполнение последним некоторых действий (отправить какую-либо информацию, либо что-то выполнить, либо и то и другое и т.д.),
- ответы (HTTP responses) — сообщения, которые сервер отправляет в ответ на клиентский запрос.
Составные части HTTP-сообщения
Любое HTTP-сообщение (как запрос, так и ответ) состоят из нескольких частей:
- Стартовая строка (start line) — используется для описания версии используемого протокола и другой служебной информации – вроде запрашиваемого ресурса или кода ответа; ее содержимое занимает ровно одну строчку.
- HTTP-заголовки (HTTP headers) — несколько строчек текста в определенном формате, которые либо дополняют запрос, либо описывают, какой тип содержимого будет в теле сообщения.
- Пустая строка, которая сообщает, что все метаданные для конкретного запроса или ответа были отправлены.
- Тело сообщения, которое содержит данные, связанные с запросом, либо документ (например HTML-страницу), передаваемый в ответе. Не обязательный фрагмент, часто может и отсутствовать.
Метод HTTP-запроса
Со структурой запросов, надеюсь, более-менее понятно. Но это ещё не всё. Прежде чем мы научимся отправлять запросы, мы должны понять какой метод запроса использовать. Есть несколько модификаций HTTP-запросов, которые называют методами:
- GET – позволяет запросить некоторый конкретный ресурс с сервера. Этот метод использует браузер при загрузке этой статьи с серверов Дзена. Впрочем, этот метод в сочетании с параметрами запроса вполне может быть использован и для отправки данных на сервер, наподобие метода POST.
- HEAD – аналог метода GET, но позволяет получить только заголовки, которые сервер бы вернул при получении GET-запроса к тому же ресурсу. Обычно используется для того, чтобы предварительно узнать размер запрашиваемого ресурса.
- POST – позволяет отправить какие-либо данные на сервер. Метод поддерживает отправку различных типов файлов, среди которых текст, изображения и другие типы данных в двоичном виде.
- PUT – используется для создания (размещения) новых ресурсов на сервере, например при отправке данных.
- PATCH – позволяет внести частичные изменения в указанный ресурс по указанному расположению.
- DELETE – позволяет удалить существующие ресурсы на сервере.
- OPTIONS – позволяет запросить информацию о сервере, в том числе информацию о допускаемых к использованию на сервере HTTP-методов.
Так какой-же метод использовать в каждом конкретном случае? Обычно это можно понять из описания API или даже структуры запроса. В примере ниже стартовая строка указывает, что в качестве метода используется GET, обращение будет произведено к ресурсу /index.html, по версии протокола HTTP/1.1:
URL
Запрос данных по HTTP-протоколу осуществляется с помощью указателя URL (Uniform Resource Locator). URL представляет собой строку, которая позволяет указать сам запрашиваемый ресурс и ряд дополнительных параметров, она состоит из следующих составных частей:
sheme://host:port/path?query
- scheme используется для указания используемого прикладного протокола (http, https, ftp, mqtt и т.д.) и всегда сопровождается двоеточием и двумя косыми чертами (://).
- host указывает местоположение ресурса, в нем может быть как доменное имя, так и IP-адрес.
- port позволяет указать номер порта, по которому следует обратиться к серверу. Оно начинается с двоеточия (:), за которым следует номер порта. При отсутствии данного элемента номер порта будет выбран по умолчанию в соответствии с указанным значением scheme (например, для http:// это будет порт 80).
- path указывает на конкретный ресурс на сервере host, к которому производится обращение. Если данное поле не указано, то сервер в большинстве случаев вернет указатель по умолчанию (например index.html).
- поле query начинается со знака вопроса (?), за которым следует одна или несколько пар «параметр=значение». В поле query могут быть переданы несколько параметров с помощью символа амперсанд (&) в качестве разделителя.
Не все компоненты необходимы для доступа к серверу, обязательно следует указать только поля sheeme и host.
На этом с теорией я, пожалуй, завяжу, а то будет очень многа букаф. Если вы желаете “расширить и углубить” свои познания в этом вопросе, рекомендую обратится к этой статье, материалы из которой я честно срерайтил при подготовке данной статьи.
Переходим к практической части.
Подопытный кролик
Для своих бесчеловечных экспериментов я буду использовать отечественный сервис Open Monitoring, про который я уже рассказывал на данном канале. Просто потому, что на текущий момент это один из немногих сервисов, который ещё принимает запросы в HTTP-формате (без TLS). А уже в следующей статье поиздеваемся над ним и с помощью HTTPS.
Итак, я создаю тестовый контроллер, в который буду просто отправлять разные данные. Как это сделать, советую прочитать тут:
Сервис графиков open-monitoring.online
В контексте данной статьи важно только упомянуть, что передавать данные будем с помощью метода GET (он самый простой) с использованием поля query:
http://open-monitoring.online/get?cid=2468&key=H9xOdR&p1=ЗНАЧ1&p2=ЗНАЧ2&p3=ЗНАЧ3
Отправка запроса с помощью фреймворка Arduino
Для создания HTTP подключения мы должны использовать класс WiFiClient:
Client класс Arduino библиотеки WiFi
Этот класс представляет собой не библиотеку для работы с HTTP-запросами, он предоставляет нам только функции транспортного уровня, то есть может только принимать и отправлять текстовые данные. Всё остальное – формирование заголовков и запроса, анализ ответа сервера – всё это придется делать что называется “ручками”. Ну что ж, приступим…
Перво-наперво подключаемся к серверу через connect(…), затем нужно передать наш запрос. Поскольку класс WiFiClient предоставляет только транспортные услуги, мы должны отправить все необходимые HTTP-заголовки, вызывая методы print, println или printf. После отправки всех данных и заголовков, отправляем пустую строку и ждем, что на это скажет сервер:
WiFiClient wifiConnect; if (wifiConnect.connect("open-monitoring.online", 80)) { // Формируем и отправляем GET-запрос для отправки данных wifiConnect.printf("GET /get?cid=2468&key=H9xOdR&p1=%f&p2=%f&p3=%f HTTP/1.1\r\n", 1.2, 2.3, 10.8); // Отправляем служебные HTTP-заголовки wifiConnect.println(F("Host: open-monitoring.online")); wifiConnect.println(F("User-Agent: ESP8266")); wifiConnect.println(F("Connection: close")); // Отправляем пустую строку, которая указывает серверу, что запрос полностью отправлен wifiConnect.println(); // Читаем ответ сервера (только первую строку) Serial.print(F("TG :: API responce code: ")); Serial.println(wifiConnect.readStringUntil('\n')); // Закрываем соединение wifiConnect.stop(); } else { Serial.println(F("#ERROR# :: Failed to connect to Telegram API")); };
Но это же самое действие можно выполнить гораздо компактнее, если сформировать HTTP-запрос за один “проход” по заранее подготовленному шаблону:
static const char* tgRequest = "GET /get?cid=2468&key=H9xOdR&p1=%f&p2=%f&p3=%f HTTP/1.1\r\n" "Host: open-monitoring.online\r\n" "User-Agent: ESP8266\r\n" "Connection: close\r\n\r\n"; ... WiFiClient wifiConnect; if (wifiConnect.connect("open-monitoring.online", 80)) { // Формируем и отправляем GET-запрос для отправки данных (сразу целиком, по шаблону) wifiConnect.printf(tgRequest, 1.2, 2.3, 10.8); // Читаем ответ сервера (только первую строку) Serial.print(F("TG :: API responce code: ")); Serial.println(wifiConnect.readStringUntil('\n')); // Закрываем соединение wifiConnect.stop(); } else { Serial.println(F("#ERROR# :: Failed to connect to Telegram API")); };
Получилось тоже самое, но запись компактнее. Из описанных выше теоретических сведений вам должно быть понятно, что за заголовки отправляются и для чего.
Код возврата сервера библиотекой никак не обрабатывается, поэтому вы должны сделать это самостоятельно. Если запись в БД прошла успешно, сервер должен вернуть код 200 OK.
Небольшие сложности могут возникнуть только при использовании методов POST или PUT, когда придется дополнительно добавлять в запрос дополнительные данные (например картинки). С текстовыми данными сложностей по прежнему никаких (пример отправки сообщений в Telegram API):
// Пробуем подключиться к Telegram API: обратите внимание - API принимает входящие запросы только по HTTPS на 443 порту if (wifiTg->connect("api.telegram.org", 443)) { // Формируем и отправляем POST-запрос для отправки сообщения wifiTg->printf("POST /bot%s/sendMessage HTTP/1.1\r\n", tgToken); // Отправляем служебные HTTP-заголовки wifiTg->println(F("Host: api.telegram.org")); wifiTg->println(F("User-Agent: ESP8266")); wifiTg->println(F("Content-Type: application/json")); wifiTg->println(F("Connection: close")); // Прикрепляем JSON-пакет с необходимыми данными (не забываем добавить \r\n в конце!) wifiTg->printf( "{\"chat_id\":%s,\"parse_mode\":\"HTML\",\"disable_notification\":0,\"text\":\"%s\"}\r\n", chatId, message ); // Отправляем пустую строку, которая указывает серверу, что запрос полностью отправлен wifiTg->println(); // Читаем ответ сервера (только первую строку) Serial.print(F("TG :: API responce code: ")); Serial.println(wifiTg->readStringUntil('\n')); // Закрываем соединение wifiTg->stop(); } else { Serial.println(F("#ERROR# :: Failed to connect to Telegram API")); };
Учитывайте, что отправка запроса на сервер может занимать длительное время – до нескольких секунд и более, на это время все процессы в контроллере будут заблокированы.
Используя этот механизм, можно создавать аналогичные запросы к любому серверу и API.
Отправка запроса с помощью фреймворка ESP-IDF
В ESP-IDF за HTTP(S) запросы отвечает библиотека esp_http_client.
#include "esp_http_client.h"
Эта библиотека предоставляет полный функционал для работы с HTTP-подключениями, в том числе и для TLS/SSL. В отличие от WiFiClient не только передает данные, но и “берет на себя” формирование всех служебных заголовков и анализ ответов сервера, вам остается только правильно настроить соединение.
Самая простая последовательность отправки HTTP-запроса к серверу состоит всего из трех шагов:
- Настроить соединение с помощью esp_http_client_init(). Эта функция создает дескриптор клиента HTTP на основе заданной конфигурации, указанной в параметрах esp_http_client_config_t. Эта функция должна вызываться первой. Не обязательно заполнять всю структуру esp_http_client_config_t (она достаточно большая) – для параметров, которые явно не определены пользователем, будут приняты значения по умолчанию.
- Выполните настроенный сеанс связи с сервером с помощью esp_http_client_perform() – открытие соединения, обмен данными и закрытие соединения (по необходимости). При этом, если настроена опция is_async = false, текущая задача будет заблокирована до ее завершения. Иначе (is_async = true) функция вернет управление сразу же, но вам придется использовать callback обработчик событий, настроенный в параметрах конфигурации esp_http_client_config_t.
- Для завершения сеанса связи вызовите esp_http_client_cleanup() – это закроет текущее соединение (если оно не было закрыто ранее) и освободит всю память, выделенную экземпляру HTTP-клиента. Эта функция должна вызываться обязательно после завершения всех операций.
Пример настройки соединения номер раз. В этом примере я указал хост, порт, путь и запрос явным образом, как отдельные параметры конфигурации (аналогично примеру выше для WiFiClient):
// Параметры конфигурации HTTP-соединения esp_http_client_config_t request; memset(&request, 0, sizeof(request)); // Параметры HTTP-соединения request.host = "open-monitoring.online"; request.port = 80; request.path = "/get"; request.user_agent = "ESP32"; // Формируем запрос request.query = "cid=2468&key=H9xOdR&p1=1&p2=test&p3=12.85"; // Транспорт TCP/IP request.transport_type = HTTP_TRANSPORT_OVER_TCP; // Запрос типа GET request.method = HTTP_METHOD_GET; // Блокировка задачи на время выполнения обмена с сервером request.is_async = false; // Не обязательные параметры, их можно не заполнять request.keep_alive_enable = false; request.timeout_ms = 60000; request.disable_auto_redirect = false; request.max_redirection_count = 0;
В большинстве случаев это достаточно удобный вариант, так как хост, порт как правило остаются неизменными. Но можно использовать и обычный URL, как все привыкли в браузере, в этом случае клиент сам распарсит URL и выберет необходимые параметры.
Пример настройки соединения номер два, с использованием URL:
// Параметры конфигурации HTTP-соединения esp_http_client_config_t request; memset(&request, 0, sizeof(request)); // Начальный URI request.url = "http://open-monitoring.online/get?cid=2468&key=H9xOdR&p1=1&p2=test&p3=12.85"; // Транспорт TCP/IP request.transport_type = HTTP_TRANSPORT_OVER_TCP; // Запрос типа GET request.method = HTTP_METHOD_GET; // Блокировка задачи на время выполнения обмена с сервером request.is_async = false; // Не обязательные параметры, их можно не заполнять request.keep_alive_enable = false; request.timeout_ms = 60000; request.disable_auto_redirect = false; request.max_redirection_count = 0;
Результат будет тот же самый.
Какой метод конфигурации использовать – решайте сами. Несмотря на кажущуюся сложность, первый вариант немного быстрее в плане скорости работы (так как не требуется парсить URL) и экономичнее в плане использования оперативки (так как host, port и path, как правило, постоянны, и их легко можно запихнуть в const char*, которая “упадет” на flash, а изменяемая часть query занимает уже меньше памяти.
Далее всё просто:
// Инициализируем соединение esp_http_client_handle_t client = esp_http_client_init(&request); if (client) { // Выполняем запрос esp_http_client_perform(client); // Освободим ресурсы esp_http_client_cleanup(client); };
Как видите, ничего сложного.
Повторное использование HTTP-соединений
Хм… но тут есть одна проблемка – в примере мы использовали неизменный запрос (query всегда один и тот же). Его можно передать на сервер много-много раз без необходимости удаления (cleanup) и повторной инициализации.
В реальности такого быть не может, нужно передавать разные данные. Самый очевидный способ – повторить все шаги для каждого запроса:
- настроили / инициализировали -> выполнили -> удалили
- настроили / инициализировали -> выполнили -> удалили
- и так до бесконечности…
Не очень красиво. Медленно и не оптимально.
Действительно, нет необходимости каждый раз инициализировать HTTP-соединение, чтобы просто отправить другие данные, можно просто воспользоваться функцией esp_http_client_set_url(client, const char *url) и “на лету” поменять URL для уже инициализированного соединения.
Более того, “на лету” можно поменять не только URL, но и метод: esp_http_client_set_method(client, esp_http_client_method_t method), например после отправки GET можно перейти на POST и наоборот, без необходимости инициализировать соединение заново.
Но вот сменить на лету host или post, увы не выйдет, поэтому придется использовать только URL, что мне лично, не очень нравится.
Используем это знание, в этом случае код принимает такой вид:
// Параметры конфигурации HTTP-соединения esp_http_client_config_t request; memset(&request, 0, sizeof(request)); // Начальный URI request.url = "http://open-monitoring.online/get?cid=2468&key=H9xOdR"; // Транспорт TCP/IP request.transport_type = HTTP_TRANSPORT_OVER_TCP; // Запрос типа GET request.method = HTTP_METHOD_GET; // Блокировка задачи на время выполнения обмена с сервером request.is_async = false; // Закрыть соединение сразу после отправки всех данных request.keep_alive_enable = false; // Таймаут передачи request.timeout_ms = 60000; // Разрешить автоматическую переадресацию без ограничений request.disable_auto_redirect = false; request.max_redirection_count = 0; // Инициализируем соединение esp_http_client_handle_t client = esp_http_client_init(&request); if (client) { // Рабочий цикл задачи while(1) { // Устанавливаем новый URL (для простоты я все равно не буду тут ничего изобретать разного) esp_http_client_set_url(client, "http://open-monitoring.online/get?cid=2468&key=H9xOdR&p1=2&p2=hello&p3=99.99"); // Выполняем запрос esp_http_client_perform(client); // Закрываем соединение, если оно открыто esp_http_client_close(client); // Пауза 3 минуты vTaskDelay(pdMS_TO_TICKS(180000)); }; // У нас порядок простой - поел, убери за собой! esp_http_client_cleanup(client); };
Разумеется, в реальном коде в функцию esp_http_client_set_url должны передаваться разные значения.
Обработка кода ответа сервера
Это всё хорошо, но как мы узнаем, получил ли сервер наши данные и выполнил ли поставленную перед ним задачу? Очень просто: используйте функцию esp_http_client_get_status_code() – она вернет стандартный целочисленный код ответа сервера (после вызова esp_http_client_perform()). По нему уже можно судить, выполнена запрошенная операция или нет.
// Выполняем запрос esp_http_client_perform(client); // Анализ результатов int response = esp_http_client_get_status_code(client); ESP_LOGI(TAG, "esp_http_client_get_status_code = %d", response);
Кроме того, имеется функция esp_http_client_get_errno(), которая возвращает код ошибки для текущей сессии.
Полный код примера будет выглядеть так:
// Функция задачи void led_exec(void *pvParameters) { // Параметры конфигурации HTTP-соединения esp_http_client_config_t request; memset(&request, 0, sizeof(request)); // Начальный URI request.url = "http://open-monitoring.online/get?cid=2468&key=H9xOdR"; // Транспорт TCP/IP request.transport_type = HTTP_TRANSPORT_OVER_TCP; // Запрос типа GET request.method = HTTP_METHOD_GET; // Блокировка задачи на время выполнения обмена с серверос request.is_async = false; // Закрыть соединение сразу после отправки всех данных request.keep_alive_enable = false; // Таймаут передачи request.timeout_ms = 60000; // Разрешить автоматическую переадресацию без ограничений request.disable_auto_redirect = false; request.max_redirection_count = 0; // Инициализируем соединение esp_http_client_handle_t client = esp_http_client_init(&request); if (client) { // Рабочий цикл задачи while(1) { // Устанавливаем новый URL (для простоты я все равно не буду тут ничего изобретать разного) esp_http_client_set_url(client, "http://open-monitoring.online/get?cid=2468&key=H9xOdR&p1=2&p2=hello&p3=99.99"); // Выполняем запрос esp_http_client_perform(client); // Закрываем соединение, если оно открыто esp_http_client_close(client); // Анализ результатов int response = esp_http_client_get_status_code(client); ESP_LOGI(TAG, "esp_http_client_get_status_code = %d", response); // Выводим информацию о свободной памяти double heap_total = (double)heap_caps_get_total_size(MALLOC_CAP_DEFAULT) / 1024.0; double heap_free = (double)heap_caps_get_free_size(MALLOC_CAP_DEFAULT) / 1024.0; double heap_min = (double)heap_caps_get_minimum_free_size(MALLOC_CAP_DEFAULT) / 1024.0; ESP_LOGI("RAM", "Heap total: %.3f kB, free: %.3f kB (%.1f%%), minimum: %.3f kB (%.1f%%)", heap_total, heap_free, 100.0*heap_free/heap_total, heap_min, 100.0*heap_min/heap_total); // Пауза 3 минуты vTaskDelay(pdMS_TO_TICKS(180000)); }; // У нас порядок простой - поел, убери за собой! esp_http_client_cleanup(client); }; vTaskDelete(NULL); }
POST-запросы
А если нам нужно отправить данные через POST-запрос? Нет проблем! Используйте функцию esp_http_client_set_post_field(client, const char *data, int len). Как видите, вы можете легко и просто добавить в запрос данные любого типа – строки или изображения, нужно лишь передать указатель на буфер и его размер.
А что если нужен более сложный сценарий обмена с сервером?
Использовать esp_http_client_perform() просто и удобно в большинстве простых случаев, но иногда его возможностей может быть явно мало. Например когда нужна какая либо “многоходовочка” с различными комбинациями. В этом случая библиотека esp_http_client предоставляет вам на выбор множество других функций, ниже я просто перечислю их:
- esp_http_client_get_url(client, char *url, const int len) – получить текущий URL из параметров клиента
- esp_http_client_set_header(client, const char *key, const char *value) – добавить нестандартный заголовок или переопределить стандартный
- esp_http_client_get_header(client, const char *key, char **value) – получить текущее значение заголовка по его ключу
- esp_http_client_delete_header(client, const char *key) – удалить заголовок из запроса
- esp_http_client_set_authtype(client, esp_http_client_auth_type_t auth_type) – установить тип авторизации на сервере
- esp_http_client_add_auth(client) – при получении кода ответа HTTP 401 от сервера этот метод можно вызвать для добавления информации об авторизации
- esp_http_client_set_username(client, const char *username) – указать имя пользователя
- esp_http_client_get_username(client, char **value) – узнать текущее имя пользователя
- esp_http_client_set_password(client, const char *password) – установить пароль
- esp_http_client_get_password(client, char **value) – хакнуть пароль 😉
- esp_http_client_open(client, int write_len) – открыть соединение с сервером и отправить служебные заголовки
- esp_http_client_fetch_headers(client) – прочитать заголовки ответа HTTP-сервера после отправки запроса и данных сервера (если они есть)
- esp_http_client_write(client, const char *buffer, int len) – само собой, отправляем данные из буфера на сервер
- esp_http_client_read(client, char *buffer, int len) – ну а тут, наоборот, читаем
- esp_http_client_get_content_length(client) – получить длину содержимого HTTP-ответа
- esp_http_client_is_chunked_response(client) – проверка на фрагментацию ответа сервера (для HTTP/2)
- esp_http_client_get_chunk_length(client, int *len) – получить длину текущего фрагмента (для HTTP/2)
- esp_http_client_is_complete_data_received(client) – проверяет, были ли все данные в ответе прочитаны без ошибок
- esp_http_client_read_response(client, char *buffer, int len) – вспомогательный API для чтения больших фрагментов данных, который выполняет внутренние вызовы esp_http_client_read несколько раз, пока не будет достигнут конец данных или пока не заполнится буфер
- esp_http_client_flush_response(client, int *len) – обработать все оставшиеся данные ответа. При этом используется внутренний буфер для повторного получения, анализа и удаления данных ответа до тех пор, пока не будут обработаны полные данные. Поскольку дополнительный пользовательский буфер не требуется, это может быть предпочтительнее esp_http_client_read_response в ситуациях, когда содержимое ответа может быть проигнорировано
- esp_http_client_close(client) – закрыть соединение, но не удалять служебные данные соединения для возможности повторного использования
Пример использования такого нестандартного сценария вы можете посмотреть в другой статье: Отправка изображений с ESP32 в telegram.
В следующий раз поговорим о том же самом, но с применением HTTPS-соединений. На ESP-IDF эта тема гораздо интереснее, чем кажется на первый взгляд.
На этом пока всё, до встречи на сайтe kotyara12.ru и на dzen-канале!
Ссылки
💠 Полный архив статей вы найдете здесь
Пожалуйста, оцените статью: