ESP32のAP機能、AsyncWebServerを立てて効率的にGPIO操作をする

norippyです。IoTLTアドベントカレンダー2023　8日目の投稿としてこの記事を書きました。
久しぶりの投稿になりますが、よろしくお願いします。

# はじめに
実は今年ESP32のAPモード(アクセスポイント)機能にハマり、この機能を使って色々なシステムを構築しました。
理由はとてもシンプル。今まではよくBLEを使っていたのですが、Appleディベロッパーの更新をやめ、App開発をしなくなったためです。

App開発をしないにしても、スマートフォンとESP32やM5Stackと通信をすることでシステムを作りたいと思うことはあり、今までESP32がWi-Fiに接続するときのSSIDとパスワードを後から登録,変更できるようにするくらいでしか使っていなかったAPモードで遊んでみることにしました。

特に今年はChatGPTの恩恵を受けて、自分が調べてもなかなか気づかないようなライブラリの存在やコードの書き方をChatGPTに教えてもらうことで、タイトルにあるような非同期処理ででアクセスポイントを使う方法を知ることができました。

この記事ではM5Stackから発売されているRoverC.ProとM5StackC Plusを題材にどのようにアクセスポイントで非同期処理を実現するのかを紹介します。
具体的にスマートフォンをリモコン代わりにRoverC.Proを動かしていきます。もちろん処理は非同期。
ここで紹介するコードを使うとこんなことができます。

https://x.com/Norio_Delux/status/1654713379888721920?s=20

全てのコードを載せておきますので、是非部品を揃えて遊んでみてください。

# 準備するもの、開発環境
今回はVisual Studio Code上でPlatform IOを使って開発をしました。
言語はArduinoになります。

ハードウェアはM5StackC plusとRoverC.Proを用意します。

https://www.switch-science.com/products/6470?_pos=1&_sid=5306afa80&_ss=r

https://www.switch-science.com/products/6617

# 使用するライブラリ & platformio.iniについて
今回のシステムを実現するために必要なライブラリは以下になります
・ [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer)
・ [ArduinoJson](https://github.com/bblanchon/ArduinoJson)
ESPAsyncWebServerが非同期のWebサーバーを構築するために使うライブラリです。
Arduino Jsonは変数のやり取りを行う時に便利なので利用しています。

今回はM5StickC Plusを使ったので、
・[M5StickCPlus](https://github.com/m5stack/M5StickC-Plus)
を使います。もちろんRoverCを使うのでそれに付随してあると便利なライブラリも活用します
・ [M5_RoverC](https://github.com/m5stack/M5-RoverC)
このライブラリでRoverのモーター処理を簡単に書くことができるようになります。

PlatformIOを使って進める場合は以下のようにplatformio.iniを設定してください

```platformio.ini
[env:m5stick-c]
platform = espressif32
board = m5stick-c
framework = arduino
lib_deps = 
	m5stack/M5StickCPlus@^0.0.8
	m5stack/M5_RoverC@^0.0.1
	https://github.com/me-no-dev/ESPAsyncWebServer.git#master
	bblanchon/ArduinoJson@^6.21.2
 ```
 
# なぜ非同期処理をするのがいいのか？
ESP32のアクセスポイントの機能を調べると、WebServerライブラリを使ってコードが書かれています。
単純なシステムであればこの方法でも何ら問題はないのですが、複数のリクエストを同時に効率よく処理したい時、特にリアルタイムでのデータ更新やインタラクティブなユーザーインターフェイスが必要なアプリケーションにおいては WebServerライブラリでは少し効率が悪いと感じる場面がでてきます。

どういうことかというと  WebServerライブラリは値の更新をしようと思ったらhtmlページを再生成してクライアントに送信する必要があるため、動的コンテンツが含まれる場合にはリソースの面で効率が悪くなってしまいます。

この問題を解決する一つの手段としてESPAsyncWebServerライブラリを使います。このライブラリはWebSocket通信をサポートしているため、クライアントとサーバー間でリアルタイムのデータ交換を可能にし、ページの一部を動的に更新することができます。

ということでESPAsyncWebServerライブラリを使って、今回はスライダーの値,表示を簡単に書き換えたり、ボタンを押している間だけRoverCを動かし、離している間はRoverCを停止させるといった機能を実現してみます。もちろんボタンを押しながら速度を変える事も可能になっています。


 # 実際のコードを見てみる
実際にコードの中にコメントを書きましたので、コメントを見ながらどのような処理をしているのかを理解していきましょう。
もちろんこのままコピペすれば動きます。

 ```main.cpp
#include <Arduino.h>
#include <M5StickCPlus.h>
#include <M5_RoverC.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

//WiFiネットワークの設定
const char *ssid = "M5StickC_RoverC";
const char *password = "12345678";

// サーバーとWebSocketの設定
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
M5_ROVERC roverc;
int servoAngle = 0;
bool grip = false;
bool release = false;

// WebSocketメッセージを処理する関数
void handleWebSocketMessage(void *arg, uint8_t *data, size_t len)
{
  AwsFrameInfo *info = (AwsFrameInfo *)arg;
  if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT)
  {
    //スマホと接続ができている場合にこの処理を実行する。
    data[len] = 0;
    Serial.println((char *)data); // Add this line
    DynamicJsonDocument doc(1024);
    deserializeJson(doc, (char *)data);
    String command = doc["command"].as<String>();
    int speed = doc["speed"].as<int>();
    
    //現在どのコマンドが押されているか、スライダーで設定された速度を読みRoverCを動かす
    if (command == "forward") //前進
    {
      roverc.setSpeed(0, speed, 0);
    }
    else if (command == "back") //後退
    {
      roverc.setSpeed(0, -speed, 0);
    }
    else if (command == "right") //右
    {
      roverc.setSpeed(speed, 0, 0);
    }
    else if (command == "left") //左
    {
      roverc.setSpeed(-speed, 0, 0);
    }
    else if (command == "right_rotation") //右旋回
    {
      roverc.setSpeed(0, 0, -speed);
    }
    else if (command == "left_rotation") //左戦隊
    {
      roverc.setSpeed(0, 0, speed);
    }
    else if (command == "grip") //アームを動かす(閉じる方に)
    {
      grip = true;
    }
    else if (command == "release") //アームを動かす(開く方に)
    {
      release = true;
    }
    else if (command == "stop") //動作を止める
    {
      roverc.setSpeed(0, 0, 0);
      grip = false;
      release = false;
    }
  }
}

// WebSocketイベントを処理する関数
void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len)
{
  switch (type)
  {
  case WS_EVT_CONNECT:
    Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str());
    break;
  case WS_EVT_DISCONNECT:
    Serial.printf("WebSocket client #%u disconnected\n", client->id());
    break;
  case WS_EVT_DATA:
    handleWebSocketMessage(arg, data, len);
    break;
  }
   // クライアントの接続、切断、データ受信を処理
}


// セットアップ関数
void setup()
{
  // M5StickCとRoverCの初期化、WiFi APの設定、サーバーとWebSocketの開始
  M5.begin();
  M5.Lcd.setRotation(3);
  M5.Lcd.fillScreen(TFT_BLACK);
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.setTextColor(TFT_WHITE);
  M5.Lcd.setTextSize(1);
  M5.Lcd.printf("M5StickC RoverC Controller\n");

  M5.Axp.ScreenBreath(10);
  WiFi.softAP(ssid, password);

//ここでwebSocketとwebServerの設定をしています。
  ws.onEvent(onEvent);
  server.addHandler(&ws);

  IPAddress myIP = WiFi.softAPIP();
  M5.Lcd.setCursor(0, 20);
  M5.Lcd.printf("AP IP: %s\n", myIP.toString().c_str());

    //ここでWebServerの画面のレイアウトや処理を書く。
    //今回はhttp://192.168.4.1/に接続する
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
            {
    String html = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0">
  
  /*CSSの内容*/  
  <title>M5StickC RoverC Control</title>
  <style>
  html, body {
  position: fixed;
  overflow: hidden;
  width: 100%;
  height: 100%;
}
body {
  display: flex;
  flex-direction: column;
  height: 100vh;
  margin: 0;
  font-family: Arial, sans-serif;
  background-color: white;
}
    h1 {
      text-align: center;
      margin-top: 1rem;
      margin-bottom: 15px;
    }
    #controller {
      display: flex;
      flex-direction: row;
      justify-content: space-between; /* ここを変更 */
      margin-top: 15px;
      margin-bottom: 15px; /* ここを追加 */
      width: 90%; /* ここを追加 */
      margin-left: auto; /* ここを追加 */
      margin-right: auto; /* ここを追加 */
    }
    .direction-buttons, .rotation-buttons {
      display: flex;
      flex-direction: column;
      justify-content: center;
    }
    .direction-buttons {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      grid-template-rows: 1fr 1fr;
      gap: 1rem;
    }
    .rotation-buttons {
      display: grid;
      grid-template-columns: 1fr 1fr;
      grid-template-rows: 1fr 1fr;
      gap: 1rem;
      margin-left: 1rem;
    }
    #forward, #back, #left, #right, #left_rotation, #right_rotation, #grip, #release {
      padding: 0.5rem 1rem;
      border: 1px solid #333;
      background-color: #fff;
      cursor: pointer;
      user-select: none;
    }
    #speed {
      display: block;
      margin: 1rem auto;
      width: 60%;
    }
    .speed-value {
      text-align: center;
      margin-bottom: 1rem;
      margin-left: 10px;
    }
#slider-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin-top: 15px;
  margin-bottom: 20px;
}
  </style>
</head>
<body>
            /*画面上でのレイアウト*/
  <h1>RoverC Controler</h1>
  <div id="controller">
    <div class="direction-buttons">
      <div></div>
      <button id="forward">↑</button>
      <div></div>
      <button id="left">←</button>
      <div></div>
      <button id="right">→</button>
      <div></div>
      <button id="back">↓</button>
      <div></div>
    </div>
    <div class="rotation-buttons">
      <button id="grip">GRIP</button>
      <button id="release">RELEASE</button>
      <button id="left_rotation">LEFT ROTATIOn</button>
      <button id="right_rotation">RIGHT ROTATION</button>
    </div>
  </div>
<div id="slider-container">
  <input id="speed" type="range" min="50" max="100" value="75">
  <div class="speed-value">Speed: <span id="speed-value">75</span></div>
</div>
<script>
    let ws = new WebSocket("ws://" + location.hostname + ":80/ws");

    ws.onopen = () => {
      console.log("WebSocket connected");
    };

    ws.onclose = () => {
      console.log("WebSocket disconnected");
    };

    ws.onmessage = (event) => {
      console.log(`Received: ${event.data}`);
    };
    
    let currentCommand = ""; // 現在のコマンド状態を保持する変数
//ここでボタンを押したときの処理を書く。ボタンにはidが振られており、押したボタンと、そのときの速度設定をjsonに格納する
    let buttons = document.querySelectorAll("button");
    buttons.forEach((button) => {
      button.addEventListener("touchstart", (event) => {
        event.preventDefault();
        currentCommand = button.id; // 現在のコマンドを更新
        let msg = {
          command: button.id,
          speed: document.getElementById("speed").value
        };
        ws.send(JSON.stringify(msg));
      });
//ここでボタンを離した時の処理を書く。ボタンにはidが振られており、押したボタンと、そのときの速度設定をjsonに格納する
      button.addEventListener("touchend", (event) => {
        event.preventDefault();
        currentCommand = "stop"; // 現在のコマンドを更新
        let msg = {
          command: "stop",
          speed: document.getElementById("speed").value
        };
        ws.send(JSON.stringify(msg));
      });
    });

    let speed = document.getElementById("speed");
    let speedDisplay = document.getElementById("speed-display");
//ここで移動スピードを設定するスライダーの値が切り替わった時に、値をjsonに格納する処理を書く。
speed.addEventListener("input", () => {
  let msg = {
    command: currentCommand,
    speed: speed.value
  };
  ws.send(JSON.stringify(msg));
  document.getElementById("speed-value").textContent = speed.value;
});

  </script>
</body>
</html>


)rawliteral";
  request->send(200, "text/html", html); });

  server.begin();//APモードを起動

//roverCの設定
  roverc.begin();
  roverc.setServoAngle(0, servoAngle);
  roverc.setSpeed(0, 0, 0);
}


// メインループ関数
void loop()
{
  // put your main code here, to run repeatedly:
  M5.update();
  ws.cleanupClients();

//スピードはroverのライブラリの関係でスピードを入力すればその速度で移動を続ける。
//しかしグリップは押している間だけ動くように作られていない。そこで、押している間だけ開いたり閉じたりするよう
//loop内に処理を追加しました。
//またサーボモーターの開く角度を制限し、モーターが壊れないように対応しています。
  if (grip && servoAngle < 80)
  {
    servoAngle++;
    roverc.setServoAngle(0, servoAngle);
  }
  else if (release && servoAngle > 0)
  {
    servoAngle--;
    roverc.setServoAngle(0, servoAngle);
  };

  delay(25);//サーボモーターの信号の周期に合わせてdelayを入れています。
}
```

# 最後に
このESPAsyncWebServerライブラリを使えば、ESP32をアクセスポイントにした時にwebsocketを使って非同期処理が簡単に書けるかなと思います。ボタン操作、スライダー操作をした時など、変化があった時だけに処理を行うことができるので、常に大きな負荷がかかっているということもありません。
特にRoverCは今回使ったESPAsyncWebServerライブラリとの相性がとても良かったように思います。

また応用すればESP32で取得したセンターの値をスマートフォン上で確認したり、スマートフォンで設定を行う時も、ESP32側の処理完了を待つインジケーター画面を表示し、完了したら自動的に閉じたり失敗したら失敗した理由を表示するということも可能です。(センサーに設定値を書き込む際、ちゃんとできたか確認することが可能)

あと今回書いたコードは結構ChatGPTのサポートを受けています。GPT-4を使いましたが、html関係の情報をしっかり学習しているので、ボタンの表示やレイアウトについては色々教えてもらいました。わからないことがあればChatGPTを活用するのをお勧め目します。

そしてこのライブラリのおかげでESP32のアクセスポイント機能でできることの幅が広がりましたので、この情報を参考にしていただけると幸いです。