ESPAsyncWebServerで、ESP32をWebAPIサーバ・Webサーバにする

ESP32で、静的なHTMLファイルのWebサーバや、POST/JSONなどのWebAPIサーバを立ち上げます。
以下のESPAsyncWebServerを使わせていただいています。

https://github.com/me-no-dev/ESPAsyncWebServer

最近になってこの存在を知ったのですが、なんでこんな便利なものに気づかなかったんだろうと思うほど、必要十分の機能を有しています。

POST/JSONで、JPEGファイルを送信して、M5Core2のLCDに表示してみます。
その中で、以下について説明していきます。

* Webサーバのセットアップ
* 静的ページのホスティング
* GETエンドポイントのホスティング
* POST/JSONエンドポイントのホスティング

ソースコードもろもろはGitHubに上げておきました。

https://github.com/poruruba/Esp32_WebServer

#PlatformIOの準備

今回は、開発環境としてVisual Studio Code＋Platform IOを利用します。

あらかじめ、platformio.iniに、ESP32が接続されているシリアルのポート番号と、利用するライブラリを指定しておきます。
ポート番号は、デバイスごとに違うため、実際に割り当てられたポート番号にしてください。

```ini:platformio.ini
[env:m5stack-core2]
platform = espressif32
board = m5stack-core2
framework = arduino
upload_port = COM8
monitor_port = COM8
lib_deps =
m5stack/M5Core2@^0.0.3
  lovyan03/LovyanGFX@^0.3.11
  bblanchon/ArduinoJson@^6.17.3
  me-no-dev/ESP Async WebServer @ ^1.2.3
  densaugeo/base64 @ ^1.2.0
```

指定しているライブラリについて少し補足します。

・M5Core2
今回採用したESP32のためのライブラリです。採用しているESP32に合わせて適するライブラリを選択してください。

・LovyanGFX
Webサーバとしては必須ではありませんが、今回作成するデモで、ESP32についているLCDに画像を表示するために使っています。

・ArduinoJson
POST/JSONでの呼び出しにおいて、JSONを扱うためのライブラリです。GET呼び出しであっても、レスポンスをJSONで返している場合にも使います。

・ESP Async WebServer
これがWebサーバの本体です。

・base64
Base64をデコードするために使っています。エンコードだけであれば、ESP32に付属のライブラリで足りていたのですが、今回作成するデモで画像データを受信する際に、Base64で受信します。

#Webサーバのセットアップ

まずは、何はなくとも、WiFiが接続された状態にする必要があります。

```cpp:src/main.cpp
#include <WiFi.h>

const char *wifi_ssid = "【WiFiアクセスポイントのSSID】";
const char *wifi_password = "【WiFiアクセスポイントのパスワード】";

void wifi_connect(const char *ssid, const char *password){
  Serial.println("");
  Serial.print("WiFi Connenting");

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED)
  {
    Serial.print(".");
    delay(1000);
  }

  Serial.println("");
  Serial.print("Connected : ");
  Serial.println(WiFi.localIP());
}

void setup() {
・・・
  wifi_connect(wifi_ssid, wifi_password);
・・・
```

そして、以下がWebサーバのセットアップです。

```cpp:src/main.cpp
#include <FS.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

#define HTTP_PORT 80
AsyncWebServer server(HTTP_PORT);

void setup() {
・・・
  DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
  DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
  server.begin();
・・・
```

addHeaderで、2つほど設定していますが、これは、CORS対応のためのものです。のちほど説明しますが、ブラウザからWebAPI呼び出しをする場合に必要です。

#静的ページのホスティング

静的ページとして、HTMLファイルやJavascriptファイルをホスティングしますが、当然ESP32内にはディレクトリ構造などのファイルシステムはないです。
そこで、SPIFFSを使います。
ESP32内の不揮発メモリにあるSPIFFSにディレクトリ構造を維持したままファイルを保持し、それを静的ページとして取得できるようにします。
まずは、SPIFFSに静的コンテンツを書き込みます。

(i)dataフォルダの作成
PlatformIOでプロジェクトを作成すると、srcやlib、include、testフォルダなどが自動的に作成するかと思いますが、同じ階層に、dataフォルダを作成します。

(ii)dataフォルダに、静的コンテンツを用意
作成したdataフォルダに、静的コンテンツ(index.htmlなど)を用意します。他の用途のファイルも置くことも想定して、dataフォルダの下にwwwフォルダを作成して、その下に静的コンテンツを置いてみました。

(iii)ファイルシステムイメージを作成
Visual Studio Codeの左側のナビゲーションボタンからPlatformIOを選択すると、いくつかのタスクを選択できます。

このうち、Project TASKS ⇒ プラットフォーム名 ⇒ Platform とたどったところに、「Build Filesystem Image」がありますので、それをクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a7f0fe3c-e6d4-e30f-dd02-6f5b826da473.png)

そうすると、dataフォルダの配下にあるファイルがFilesystem Imageとしてアーカイブされます。この時点ではまだESP32には書き込まれていません。

(iv)ファイルシステムイメージをESP32に書き込み

同様に、Project TASKS ⇒ プラットフォーム名 ⇒ Platform とたどったところに、「Upload Filesystem Image」がありますので、それをクリックします。
これで、ESP32にFilesystem Imageが書き込まれます。

(v)SPIFSSを利用する準備

書き込んだSPIFFSを使う準備をします。

```cpp:src/main.cpp
#include <SPIFFS.h>
#define FORMAT_SPIFFS_IF_FAILED true

void setup() {
・・・
  SPIFFS.begin(FORMAT_SPIFFS_IF_FAILED);
・・・
```

次はWebServerで静的コンテンツを格納したSPIFSSを参照する部分です。setup()のserver.begin()の前に記載します。

```cpp:src/main.cpp
void notFound(AsyncWebServerRequest *request){
  if (request->method() == HTTP_OPTIONS){
    request->send(200);
  }else{
    request->send(404);
  }
}

setup(){
・・・
  server.serveStatic("/", SPIFFS, "/www/").setDefaultFile("index.html");
  server.onNotFound(notFound);

  DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
  DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
  server.begin();
・・・
```

大事なのは ```server.setservStatic()``` の部分です。URLのパス”/”に、SPIFFSに書き込んだFilesystem Imageのうち、/www/フォルダを割り当てる、という意味になります。

```server.notFound()``` が、URLで指定されたパスに合致するものが見つからなかったときの振る舞いを指定しています。
すべてに対してステータスコード404を返すわけではなく、HTTP_OPTIONSのときだけ200を返すようにしています。これは、CORS対応であり、CORSプリフライトリクエストを正常処理とするためのものです。

#GET呼び出しのハンドリング

```cpp:src/main.cpp
  server.on("/get", HTTP_GET, [](AsyncWebServerRequest *request) {
    AsyncWebParameter *p = request->getParam("download");
    if( p != NULL )
      Serial.println(p->value());

    request->send(200, "text/plain", "Hello, world");
  });
```

見ての通りで、説明の余地はないと思います。
QueryStringを取得してSerial.printlnしたのち、text/plainの文字列を返しています。

ほかにもいろいろな記載方法や機能がありますので、以下のページを参考にしてください。

https://github.com/me-no-dev/ESPAsyncWebServer

#POST/JSON呼び出しのハンドリング

POST呼び出しもGET呼び出しと同じなのですが、JSONを扱いやすくなるような処理の方法があります。

```cpp:src/main.cpp
  AsyncCallbackJsonWebHandler *handler = new AsyncCallbackJsonWebHandler("/post", [](AsyncWebServerRequest *request, JsonVariant &json) {
    JsonObject jsonObj = json.as<JsonObject>();
    int p1 = jsonObj["p1"] | -1;

    const char *p_param = jsonObj["param"];
    if( p_param != NULL ){
      if (decode_base64_length((unsigned char *)p_param) <= sizeof(buffer) ){
        int length = decode_base64((unsigned char *)p_param, buffer);
        lcd.drawJpg(buffer, length);
      }
    }

//    request->send(200, "application/json", "{}");
    AsyncJsonResponse *response = new AsyncJsonResponse();
    JsonObject root = response->getRoot();
    root["p1"] = p1;
    root["message"] = "Hello World";
    response->setLength();
    request->send(response);
  });
  server.addHandler(handler);
```

JsonObjectが、JSONを扱いやすくしています。詳細は、ArduinoJSONのドキュメントを参考にしてください。

https://arduinojson.org/v6/doc/
　Deserialization tutorial
　Serialization tutorial

例えば、レスポンスが空で、ArduinoJsonの力を借りるまでもない場合は、
```request->send(200, "application/json", "{}");```
でも大丈夫です。

#選択的にSPIFFSのコンテンツを返す

リクエストの内容を見て、返すSPIFFSのコンテンツを変えたい場合は以下のようにします。

```cpp:src/main.cpp
  server.on("/image", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(SPIFFS, "/www/img/test.jpg", "image/jpeg");
  });
```

#ソースコード

最後にESP32のソースコードを示します。

```cpp:src/main.cpp
#define LGFX_AUTODETECT // 自動認識

#include <M5Core2.h>
#include <LovyanGFX.hpp>
#include <WiFi.h>
#include <FS.h>
#include <SPIFFS.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
#include <ArduinoJson.h>
#include "base64.hpp"

const char *wifi_ssid = "【WiFiアクセスポイントのSSID】";
const char *wifi_password = "【WiFiアクセスポイントのパスワード】";

#define HTTP_PORT 80
#define FORMAT_SPIFFS_IF_FAILED true
#define BUFFER_SIZE 30 * 1024
unsigned char buffer[BUFFER_SIZE];

AsyncWebServer server(HTTP_PORT);
static LGFX lcd;

void wifi_connect(const char *ssid, const char *password);

void notFound(AsyncWebServerRequest *request){
  if (request->method() == HTTP_OPTIONS){
    request->send(200);
  }else{
    request->send(404);
  }
}

void setup() {
  Serial.begin(9600);
  SPIFFS.begin(FORMAT_SPIFFS_IF_FAILED);

  wifi_connect(wifi_ssid, wifi_password);

  lcd.init();

  server.on("/get", HTTP_GET, [](AsyncWebServerRequest *request) {
    AsyncWebParameter *p = request->getParam("download");
    if( p != NULL )
      Serial.println(p->value());

    request->send(200, "text/plain", "Hello, world");
  });

  AsyncCallbackJsonWebHandler *handler = new AsyncCallbackJsonWebHandler("/post", [](AsyncWebServerRequest *request, JsonVariant &json) {
    JsonObject jsonObj = json.as<JsonObject>();
    int p1 = jsonObj["p1"] | -1;

    const char *p_param = jsonObj["param"];
    if( p_param != NULL ){
      if (decode_base64_length((unsigned char *)p_param) <= sizeof(buffer) ){
        int length = decode_base64((unsigned char *)p_param, buffer);
        lcd.drawJpg(buffer, length);
      }
    }

//    request->send(200, "application/json", "{}");
    AsyncJsonResponse *response = new AsyncJsonResponse();
    JsonObject root = response->getRoot();
    root["p1"] = p1;
    root["message"] = "Hello World";
    response->setLength();
    request->send(response);
  });
  server.addHandler(handler);

  server.on("/image", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(SPIFFS, "/www/img/test.jpg", "image/jpeg");
  });

  server.serveStatic("/", SPIFFS, "/www/").setDefaultFile("index.html");
  server.onNotFound(notFound);

  DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
  DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
  server.begin();
}

void loop() {
  // put your main code here, to run repeatedly:
}

void wifi_connect(const char *ssid, const char *password){
  Serial.println("");
  Serial.print("WiFi Connenting");

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED)
  {
    Serial.print(".");
    delay(1000);
  }

  Serial.println("");
  Serial.print("Connected : ");
  Serial.println(WiFi.localIP());
}
```

#デモ

SPIFFSに保持する静的コンテンツにデモを用意しておきました。
画像ファイルを指定すると、320x240のサイズにリサイズして、ESP32にPOST/JSONで送信して、ESP32のLCDに表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/fa883e2c-0632-d3bd-0a38-35b71c986011.png)

以下が、Javascript部です。
POST/JSONポストのURLが/postになっていて、相対アドレスになっているのがわかるかと思います。

```js:data/www/public/js/start.js
'use strict';

//const vConsole = new VConsole();
//window.datgui = new dat.GUI();

const http_url = "/post";

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    data: {
        target_width: 320,
        target_height: 240,
        quality: 50,
    },
    computed: {
    },
    methods: {
        image_click: function (e) {
            e.target.value = '';
        },
        image_open: function (e) {
            this.image_open_file(e.target.files[0]);
        },
        image_open_file: function (file) {
            if (!file.type.startsWith('image/')) {
                alert('画像ファイルではありません。');
                return;
            }

            var reader = new FileReader();
            reader.onload = (theFile) => {
                this.image_image = new Image();
                this.image_image.onload = () => {
                    this.image_scale_change();
                };
                this.image_image.src = reader.result;
            };
            reader.readAsDataURL(file);
        },
        image_scale_change: function () {
            var image = this.image_image;
            var scale = image.width / this.target_width;
            if (scale < image.height / this.target_height )
                scale = image.height / this.target_height;

            var canvas = document.querySelector('#canvas_image');
            var context = canvas.getContext('2d');

            context.drawImage(image, 0, 0, image.width, image.height, 0, 0, Math.round(image.width / scale), Math.round(image.height / scale ));
            if( !confirm("画像を転送しますか？") )
                return;
            
            this.image_transfer();
        },
        image_transfer: async function(){
            try{
                var canvas = document.querySelector('#canvas_image');
                var dataURL = canvas.toDataURL('image/jpeg', this.quality / 100);
                var index = dataURL.indexOf(',');
                if (index >= 0) {
                    var params = {
                        param: dataURL.slice(index + 1)
                    };
                    await do_post(http_url, params);
                    alert("転送しました。");
                }
            }catch(error){
                alert(error);
            }
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);

/* add additional components */

window.vue = new Vue( vue_options );

function do_post(url, body) {
    const headers = new Headers({ "Content-Type": "application/json" });

    return fetch(url, {
        method: 'POST',
        body: JSON.stringify(body),
        headers: headers
    })
        .then((response) => {
            if (!response.ok)
                throw 'status is not 200';
            return response.json();
            //    return response.text();
            //    return response.blob();
            //    return response.arrayBuffer();
        });
}
```

#はまった点

はまった点を上げておきます。

・ブラウザ上のJavascriptでPOST/JSONする際に、Content-Typeは、application/json指定だけにしてください。たとえば、```"Content-Type": "application/json; charset=utf-8"``` とすると、ESPAsyncWebServerは受け取ってくれません。
・以下の記載は、他のエンドポイント指定の後(server.begin()の前)にしてください。他のエンドポイントの指定がserver.serveStaticの後にあると、受け取ったエンドポイントの検索としてSPIFSSが先に発生して、反応が遅くなります。
  ```server.serveStatic("/", SPIFFS, "/www/").setDefaultFile("index.html");```


#最後に

こんな便利なのがあったなんで、早く知っておきたかった。。。

以上