Перейти к содержимому

Добрый день, уважаемый читатель! Сегодня поговорим о том, как выполнять различные HTTP-запросы из устройств на базе:

  • ESP8266 или ESP32 под управлением фреймворка Arduino
  • ESP32 под управлением фреймворка ESP-IDF

В данной статье рассмотрим самый простой вариант – без шифрования, а в следующей части марлезонского балета поговорим как прикрутить ко всему этому TLS-шифрование. Тема, в общем-то не очень сложная – можете считать данную статью как всего-лишь предисловием к статье о TLS-шифровании.

 

Для чего нужны HTTP-запросы в вашем устройстве? С помощью HTTP устройства могут получать какие-либо данные с различных web-серверов либо “общаться” с различными внешними сервисами посредством API (Application Program Interface – программный интерфейс для приложений). Ну например:

В общем спектр применений HTTP-сообщений в системах с зачатками разума довольно широк. Я не буду сильно углубляться в теорию, об этом много и подробно написано в этих ваших интернетах, но некоторые аспекты осветить надо, чтобы в дальнейшем было понятно, что и зачем.


Немножко теории

HTTP сообщения – это протокол обмена данными между клиентом и сервером в текстовом виде. Есть два типа сообщений:

  • запросы (HTTP requests) — сообщения, которые отправляются клиентом на сервер, чтобы вызвать выполнение последним некоторых действий (отправить какую-либо информацию, либо что-то выполнить, либо и то и другое и т.д.),
  • ответы (HTTP responses) — сообщения, которые сервер отправляет в ответ на клиентский запрос.

Составные части HTTP-сообщения

Любое HTTP-сообщение (как запрос, так и ответ) состоят из нескольких частей:

  1. Стартовая строка (start line) — используется для описания версии используемого протокола и другой служебной информации – вроде запрашиваемого ресурса или кода ответа; ее содержимое занимает ровно одну строчку.
  2. HTTP-заголовки (HTTP headers) — несколько строчек текста в определенном формате, которые либо дополняют запрос, либо описывают, какой тип содержимого будет в теле сообщения.
  3. Пустая строка, которая сообщает, что все метаданные для конкретного запроса или ответа были отправлены.
  4. Тело сообщения, которое содержит данные, связанные с запросом, либо документ (например HTML-страницу), передаваемый в ответе. Не обязательный фрагмент, часто может и отсутствовать.

Источник: mozilla.org

Метод HTTP-запроса

Со структурой запросов, надеюсь, более-менее понятно. Но это ещё не всё. Прежде чем мы научимся отправлять запросы, мы должны понять какой метод запроса использовать. Есть несколько модификаций HTTP-запросов, которые называют методами:

  • GET – позволяет запросить некоторый конкретный ресурс с сервера. Этот метод использует браузер при загрузке этой статьи с серверов Дзена. Впрочем, этот метод в сочетании с параметрами запроса вполне может быть использован и для отправки данных на сервер, наподобие метода POST.
  • HEAD – аналог метода GET, но позволяет получить только заголовки, которые сервер бы вернул при получении GET-запроса к тому же ресурсу. Обычно используется для того, чтобы предварительно узнать размер запрашиваемого ресурса.
  • POST – позволяет отправить какие-либо данные на сервер. Метод поддерживает отправку различных типов файлов, среди которых текст, изображения и другие типы данных в двоичном виде.
  • PUT – используется для создания (размещения) новых ресурсов на сервере, например при отправке данных.
  • PATCH – позволяет внести частичные изменения в указанный ресурс по указанному расположению.
  • DELETE – позволяет удалить существующие ресурсы на сервере.
  • OPTIONS – позволяет запросить информацию о сервере, в том числе информацию о допускаемых к использованию на сервере HTTP-методов.

Так какой-же метод использовать в каждом конкретном случае? Обычно это можно понять из описания API или даже структуры запроса. В примере ниже стартовая строка указывает, что в качестве метода используется GET, обращение будет произведено к ресурсу /index.html, по версии протокола HTTP/1.1:

Источник: mozilla.org

 

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-запроса к серверу состоит всего из трех шагов:

  1. Настроить соединение с помощью esp_http_client_init(). Эта функция создает дескриптор клиента HTTP на основе заданной конфигурации, указанной в параметрах esp_http_client_config_t. Эта функция должна вызываться первой. Не обязательно заполнять всю структуру esp_http_client_config_t (она достаточно большая) – для параметров, которые явно не определены пользователем, будут приняты значения по умолчанию.
  2. Выполните настроенный сеанс связи с сервером с помощью esp_http_client_perform() – открытие соединения, обмен данными и закрытие соединения (по необходимости). При этом, если настроена опция is_async = false, текущая задача будет заблокирована до ее завершения. Иначе (is_async = true) функция вернет управление сразу же, но вам придется использовать callback обработчик событий, настроенный в параметрах конфигурации esp_http_client_config_t.
  3. Для завершения сеанса связи вызовите 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 предоставляет вам на выбор множество других функций, ниже я просто перечислю их:

Пример использования такого нестандартного сценария вы можете посмотреть в другой статье: Отправка изображений с ESP32 в telegram.

 


В следующий раз поговорим о том же самом, но с применением HTTPS-соединений. На ESP-IDF эта тема гораздо интереснее, чем кажется на первый взгляд.

На этом пока всё, до встречи на сайтe kotyara12.ru и на dzen-канале!

 


Ссылки

  1. Пример к текущей статье
  2. ESP-IDF :: ESP HTTP Client
  3. GitHub :: esp_http_client.h
  4. reDataSend

💠 Полный архив статей вы найдете здесь


Пожалуйста, оцените статью:
[ 5 из 5, всего 3 оценок ]

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *