はじめに

ESP32からウェブサーバーへHTTPリクエストを送るときは、HTTPClient というライブラリを使うと拍子抜けするほど簡単に書けます。ポイントは、GETでもPOSTでも通信の手順(型)がほとんど同じだということです。まずその型を頭に入れてしまえば、あとは送る中身を変えるだけで応用が効きます。

HTTPClientの4手:begin→GET/POST→getString→end

やることは、接続を開いて(http.begin)、リクエストを送り(http.GET()http.POST())、返ってきた本文を受け取り(http.getString())、接続を閉じる(http.end())——この4手です。GETとPOSTの違いは、真ん中で呼ぶメソッドと、POSTのときだけ送信する本文を渡すかどうかだけ。この記事では、テスト用のダミーサーバーを用意したうえで、GET・POST・HTTPSの順に見ていきます。

開発環境

この記事は、次の環境で動作を確認しています。

項目
ボードESP32-DevKitC
IDEPlatformIO
ファームウェアarduino-esp32 #2.0.2
HTTPClientver 2.0.0

テスト用のダミーサーバーを用意する

いきなり本番のサーバーに投げる前に、手元でリクエストを受け止めてくれる小さなサーバーがあると開発がぐっと楽になります。Pythonの標準ライブラリだけで書けるので、次の構成でファイルを2つ用意します。

.
├── dummy_root
│   └── status.json
└── dummy_server.py

dummy_server.py は、/status へのGETにJSONを返し、/data へのPOSTを受け取って中身をログに出すだけの内容です。

from http.server import HTTPServer, BaseHTTPRequestHandler
import os
import re
import logging
import socket

host_list = socket.gethostbyname_ex(socket.gethostname())
lan_address = host_list[2][1]

dirname = os.path.dirname(__file__)
server_ip = "0.0.0.0"
server_port = 9998

lan_url = "http://{}:{:d}".format(lan_address, server_port)
url = "http://{}:{:d}".format(server_ip, server_port)

os.chdir(dirname + "/dummy_root/")

def load_file(path):
    with open(path, mode='r') as f:
        s = f.read()
        return s.encode('utf-8')

class S(BaseHTTPRequestHandler):

    def do_GET(self):
        m = re.match(r'^/status$', self.path)
        if m != None:
            self.send_response(200)
            self.send_header('Content-type', 'application/json')
            self.end_headers()
            self.wfile.write(load_file("status.json"))

    def do_POST(self):
        print('======================= POST =======================');
        print(self.path)

        m = re.match(r'^/data$', self.path)
        if m != None:
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length)
            print('======================= HTTP Request =======================');
            logging.info("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n",
                    str(self.path), str(self.headers), post_data.decode('utf-8'))

            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write('{"status":"ok"}'.encode('utf-8'))

def web_server():
    logging.basicConfig(level=logging.INFO)
    httpd = HTTPServer(('', server_port), S)
    logging.info('Starting httpd...\n')
    httpd.serve_forever()

if __name__ == "__main__":
    print(lan_url)
    print(url)
    web_server()

冒頭の gethostbyname_ex は、起動時にLAN側のURLを表示するための便宜的な処理です。環境によってはLANのIPアドレスをうまく拾えず別の値が出ることがあるので、ESP32側から接続するアドレスは、後述のコードのようにPCの実際のIPアドレス(ipconfigifconfig で確認できます)を直接指定するのが確実です。status.json の中身はごく簡単なもので構いません。

{
    "TIMESTAMP": "2022-10-30T04:07:00",
    "STATUS": 1
}

GETでステータスを取りにいく

GETは、サーバーに置いておいた情報をESP32側から取りにいく使い方に向いています。たとえばサーバー上に「動作モード」のような状態を1つ置いておき、ESP32が定期的に読みにいって振る舞いを切り替える、といった具合です。AWS IoT Coreのデバイスシャドウに近い考え方ですが、ちょっとした用途ならMQTTを持ち出さずHTTPで十分こと足ります。

#include <HTTPClient.h>
#include <WiFi.h>

#include "Arduino.h"

#ifndef WIFI_SSID
#define WIFI_SSID "xxxxx"  // WiFi SSID (2.4GHz only)
#endif

#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD "xxxxx"  // WiFiパスワード
#endif

String url = "http://192.168.61.5:9998/status";

void setup() {
    Serial.begin(115200);

    WiFi.mode(WIFI_STA);
    delay(500);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}

void loop() {
    WiFiClient client;
    HTTPClient http;

    if (!http.begin(client, url)) {
        Serial.println("Failed HTTPClient begin!");
        return;
    }

    http.addHeader("Content-Type", "application/json");
    int responseCode = http.GET();
    String body = http.getString();
    Serial.println(responseCode);
    Serial.println(body);

    http.end();

    delay(5000);
}

setup() でWi-Fiにつなぎ、loop() のなかで HTTPClientWiFiClient を渡して http.GET() を呼ぶ、という先ほどの型そのままです。戻り値の responseCode にはHTTPのステータスコード(200404 など)が入り、本文は http.getString() で取り出せます。返ってきた内容がJSONなら、その文字列がそのまま String で得られるので、あとはパースするだけです。ESP32でJSONを扱うなら json11 のような軽量ライブラリが便利です。

POSTでセンサの値を送る

センサで測った値をサーバーに投げて記録したい、という場面ではPOSTを使います。コードはGETとほとんど変わらず、真ん中を http.POST(json) に差し替えて、送りたい本文を渡すだけです。

#include <HTTPClient.h>
#include <WiFi.h>

#include "Arduino.h"

#ifndef WIFI_SSID
#define WIFI_SSID "xxxxx"  // WiFi SSID (2.4GHz only)
#endif

#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD "xxxxx"  // WiFiパスワード
#endif

String url = "http://192.168.61.5:9998/data";

void setup() {
    Serial.begin(115200);

    WiFi.mode(WIFI_STA);
    delay(500);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}

void loop() {
    WiFiClient client;
    HTTPClient http;
    String json = R"({"device_name": "esp32", "temperature": 21.6})";

    if (!http.begin(client, url)) {
        Serial.println("Failed HTTPClient begin!");
        return;
    }
    http.addHeader("Content-Type", "application/json");
    int responseCode = http.POST(json);
    String body = http.getString();

    Serial.println(responseCode);
    Serial.println(body);

    http.end();

    delay(5000);
}

先ほどのダミーサーバーを立ち上げた状態でこれを動かすと、サーバー側のログに次のようなリクエストが届きます。ヘッダーとボディがそのまま出るので、送った内容が正しく届いているか確認できます。

INFO:root:POST request,
Path: /data
Headers:
Host: 192.168.61.5:9998
User-Agent: ESP32HTTPClient
Connection: keep-alive
Accept-Encoding: identity;q=1,chunked;q=0.1,*;q=0
Content-Type: application/json
Content-Length: 44

Body:
{"device_name": "esp32", "temperature": 21.6}

HTTPSでアクセスする

ここまではHTTPでしたが、外部のサービスにつなぐならSSL で暗号化されたHTTPSになります。ESP32でHTTPSを扱うやり方は二通りあって、手軽さで選ぶか、細かい制御ができるかで選ぶかの違いです。いずれの例もサーバー証明書の検証を省いているので、その点は最後に触れます。

手軽なのは、HTTP版と同じ HTTPClient をそのまま使う方法です。呼び出しの型は変わらず、接続先をHTTPSのURLにするだけで済みます。

#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>

#ifndef WIFI_SSID
#define WIFI_SSID ""  // TODO WiFi SSID (2.4GHz only)
#endif

#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD ""  // TODO WiFiパスワード
#endif

#ifndef END_POINT
#define END_POINT ""  // TODO アクセス先URL
#endif

void setup() {
    Serial.begin(115200);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.println("Connecting to WiFi...");
    }

    Serial.println("Connected to WiFi");
}

void loop() {
    if ((WiFi.status() == WL_CONNECTED)) {

        HTTPClient http;

        http.begin(END_POINT);
        int httpCode = http.GET();

        if (httpCode > 0) {
            String payload = http.getString();
            Serial.println(payload);
        }
        http.end();
    }
    delay(10000);
}

HTTPClient は、リクエストの組み立てからステータスコードやヘッダー・本文の取り出しまでをまとめて面倒みてくれる高レベルなライブラリです。内部では WiFiClientWiFiClientSecure を使ってTCP接続を張っていて、その細かいところを隠してくれているので、GET・POSTを送るだけならこれがいちばん短く書けます。

もう一段細かく制御したいときは、WiFiClientSecure を直接使います。HTTPのリクエスト行やヘッダーを自分で組み立てるぶん手間は増えますが、その自由度が要る場面もあります。

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>

#ifndef WIFI_SSID
#define WIFI_SSID ""  // TODO WiFi SSID (2.4GHz only)
#endif

#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD ""  // TODO WiFiパスワード
#endif

#ifndef HOST
#define HOST ""  // TODO アクセス先のホスト
#endif

#ifndef PAGE
#define PAGE ""  // TODO アクセス先のページ
#endif

void setup() {
    Serial.begin(115200);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.println("Connecting to WiFi...");
    }

    Serial.println("Connected to WiFi");
}

void loop() {
    if (WiFi.status() == WL_CONNECTED) {
        WiFiClientSecure client;
        client.setInsecure(); // サーバー証明書の検証なしで接続

        if (!client.connect(HOST, 443)) {
            Serial.println("connection failed");
            return;
        }

        String request = "GET " + String(PAGE) + " HTTP/1.1\r\nHost: " + String(HOST) + "\r\nConnection: close\r\n\r\n";
        client.print(request);
        Serial.println("request sent");

        bool isHeader = true;
        String header, body;

        while (client.connected() || client.available()) {
            if (client.available()) {
                String line = client.readStringUntil('\n');
                if (line == "\r") {
                    isHeader = false;
                    continue;
                }

                if (isHeader) {
                    header += line + "\n";
                } else {
                    body += line + "\n";
                }
            }
        }
        client.stop();

        Serial.println("Headers:\n" + header);
        Serial.println("Body:\n" + body);
    }

    delay(10000);
}

WiFiClientSecure はSSL/TLSでの暗号化通信を担う低レベルなクライアントで、client.setInsecure() を呼ぶと証明書の検証をせずに接続します。ヘッダーの組み立てやレスポンスの読み取りは自分で書くことになりますが、そのぶん通信の細部まで手を入れられます。

証明書の検証を省くリスク

上の例はどちらも setInsecure() で証明書の検証を飛ばしています。動作確認のあいだは手軽で助かりますが、そのまま本番に持っていくのはおすすめできません。証明書の検証には、通信の相手が本物のサーバーかどうかを確かめる役割があるからです。

検証を省いても通信自体は暗号化されるので、途中の第三者に中身をそのまま読まれることは防げます。ただし、暗号化されているのが「本当に正しい相手」との通信だとは限りません。攻撃者が偽の証明書で正規サーバーになりすませば、ESP32は気づかずに偽サーバーへつなぎ、送ったデータは相手に筒抜けになります。いわゆる中間者攻撃です。暗号化は「盗み見」を防ぎますが、「なりすまし」までは防げない、というのが検証を省いたときの弱点です。

というわけで、setInsecure() は手元でのテストまでにとどめ、公開するサービスや機密性のあるデータを扱うときは、きちんと証明書を検証する形にしておくのが安全です。

関連記事

ESP32で通信の型がつかめたら、実際に何を送るか(センサ)と、どう開発するかに広げていくのが自然な流れです。