LoginSignup
2
4

カメラ付きラジコン作製(ESP32搭載ボード)

Last updated at Posted at 2023-11-18

0.やりたいこと

カメラ付きのラジコンを作製して、スマホからカメラの映像を見ながら操作できるようにしたい

実現するために心がけたこと
・できるだけ安価に、入手性よく
・自分が初心者なので、分かりやすいコードで実現したい

以下のQiitaでの説明は、ざっくりからくわしくの流れでできるだけ説明し、分かりやすく残していきたい。自分がまたやりたいーと思っても、すぐ忘れてしまうのでわかりやすく残しておかないとまたゼロからやり始めないといけない。。。
IMG_2179.png

1.ハード面

1-1.ざっくり何が必要?

ざっくり、下記が必要と思われる。
①制御基板
ESP32を搭載し、カメラ付きのボード

②モータドライバ
タイヤを動かすモータへの接続はモータドライバを使用する

③ギヤモータ
タイヤを動かすモータは力があり、安価なギヤモータを使用する

④バッテリー(モバイルバッテリーと電池)
ESPボードへの電源問題は常にあり、ノイズがのって制御基板が上手く動かないことがあるので、信頼できる安価なモバイルバッテリーとする
モータドライバの電源は単三電池3本の4.5Vとする

⑤操作用のスマホ
僕はiphone13だけど、WiFiが使えれば何でもいいと思う

⑥ジャンパー線など電線

どんな感じになるか構成図は以下
IMG_2191.png

1-2.具体的に何をどう接続する

1-2-1.まず何を具体的に購入するか

①制御ボード
ESP32-Wrover camボード(2023/11月現在2180円)
Amazonで購入可能で、いつも問題となる技適は有りのESP32モジュール
購入すればチュートリアルをdownloadできていろんな使い方が分かる説明書付き
リンクは以下
ESP32 Wrover camボード

②モータドライバ
L9110S搭載のモータドライバ(2023/11月現在795円 5個入り)
つなぎ方がシンプルで2つのモータを回したい僕にとっては最適で安価
入力電圧:
リンクは以下
モータドライバL9110S搭載

③ギヤモータ
TTモータ(2023/11月現在1199円8個入り)
なんといっても激安なギヤモータはこのttモータ
お世話になっています。モータだけでも高いのにギヤが組み込まれてこの値段
モータへの配線接続は半田作業が必要ですが、半田も100均で売られているぐらい身近にありますから使いやすいですね。入力していい電圧に幅があるのもうれしい。
入力電圧:
リンクは以下
TTモータ

⑤操作用スマホはなんでもいいです

④ジャンパー線と電線
僕が良く使うのは以下のリンクのジャンパー線
電線はこのぐらいの電子工作であれば22AWGという規格サイズの電線で十分だと思われる。
リンクは以下
電線

シャンパー線

1-2-2.電線の接続

接続先は下記の手書き図と表の通り
IMG_2192.png

ESP32基板 モータドライバ モータ 電源
GPIO12 A-1B モータA(左モータ) -
GPIO14 A-1A モータA(左モータ) -
GPIO13 B-1B モータB(右モータ) -
GPIO15 B-1A モータB(右モータ) -
- VCC - +プラス +4.5V(電池)
GROUND GROUND - -マイナス(電池)
CタイプUSB端子 - - USB端子(モバイル)

今回試行錯誤したので電線の中継がいくつも入っているが、気にされず。
電線のつなぎは本当は収縮チューブとかで保護すると見栄えはいいのだが、今回はパス。

1-2-3.本体の組立

まずはbody
3Dプリンタでかっこよく設計してもよかったけど、モデル作製から印刷まで結構時間がかかってしまうので、3Dプリンタは要所の部品で使用することにして本体のベースやタイヤは木材を切り出して使用することにした。
木材を使用する利点は糸鋸があれば比較的自由に形が作れるのと、木ねじを使えば簡単に固定できること。
手書きですが、下記手書き図の寸法でベースとタイヤを切り出し、必要な箇所に穴をあける
IMG_2194.png

IMG_2189.png
IMG_2195.png

モータドライバを写真の位置い組付け、モータの左右を間違えないように注意して組み付ける。モバイルバッテリーと電池を両面テープで固定する。モバイルバッテリーはESP基板へUSB端子を抜き差ししてON/OFFするとして、モータドライバ電源にあたる電池は電池の抜き差しでON/OFFでもよかったが、スイッチを入れることにした。

1-2-4.3Dプリンタパーツの作製と取付

3Dプリンタパーツは以下2点で必要になったので、モデル作製を行い印刷した。
・ギヤモータとベースとの固定
・ギヤモータ軸とタイヤとの固定
IMG_2187.jpeg
IMG_2188.jpeg
IMG_2190.png

そんな激ムズなモデルではないので、モデル作製はFusion360でもFreeCadでもなんでもいいと思う。3Dプリンタ印刷はコスパが高くコンパクトでいいなと思っているENTINAの3Dプリンタが最近いいなと思っているが、何を使用してもいいと思う。

3Dプリントだけでかなりの話ができるので、ここではやめておいて、他の方がたくさん分かりやすい記事を書いておられるのでそちらを参照ください。

1-2-5.ハード面の知っておくべきこと

【モータドライバはなぜ必要か?】
簡単に言うとESP基板からモータを動かすほどの電流を流すことができないから。
もし無理に流してしまうと基板自体が壊れてしまう。
そこで、モータと基板との間のクッションとしてモータドライバを挿入する。
モータドライバは基板からモータを動かせと命令されたら、その信号が担当しているモータを動かすよう電流を流す。この電流はモータドライバを動かしている電源から頂いているので基板から流れたものではない。
このような理由からモータを基板から動かす場合には、一般的にモータドライバと言われる
制御基板が必要。

【ESP基板とモータドライバの電源を共用する(グランドを共用)と動かない時がある】
これは永遠のテーマなのかもしれないが、モータなのでそもそもノイズを発生しやすいと考えられ、かつ、モータドライバからのノイズもあると思われるので、電源をESP基板とモータドライバで共用すると不具合になることがあった。適切にコンデンサを挿入してノイズ低減を図ればいいと思うがそこまでの検証はできていない。グランド(電気でいうとマイナス)を共用することでノイズがそこに乗ってくるのかもしれない。ただし不具合のあったのはESP基板の電源とモータドライバの電源を同じ電池で共用した場合で、これを別電源して、特にESP基板側をモバイルバッテリー、モータドライバ側を電池にした場合、不具合はなかった。どちらも別電池にした場合は不具合が出ており、単純に別電源にするだけでよいというものではないみたい。モバイルバッテリーで上手くいったのは、おそらくモバイルバッテリー制御回路内にノイズのキャンセリング回路があるからだと思う。

1-2-6.ハードの完成写真

もっと屋根とかつけて装飾してもらえればもっと素敵になります。
本質的なとこは言えたので、とりあえず僕のはここで終了とします。
IMG_2186.jpeg
IMG_2185.jpeg
IMG_2184.jpeg

2.ソフト面

2-1.開発環境と必要な下準備

2-1-1.開発環境

Arduino IDEを使用(2023/11月現在 最新version 2.2.1)
以下リンク
Arduino IDE ダウンロード

2-1-2.必要な下準備(ライブラリの入手)

Ardino IDEで開発していくのに、まずIDEをダウンロードしたら、必要なボードを追加しなくちゃいけない。
その方法はほかのみなさんが分かりやすい記事を書いてくださっているので、そちらのリンクを見て頂き、設定をする。

以下リンク
参考になったリンク ESP使用のためのボード追加

大まかにいうと、以下のアドレスをフォームに追加するとESP32の標準的なライブラリが一括で入手できる。
https://dl.espressif.com/dl/package_esp32_index.json

その他の特殊なライブラリは以下の方法で入手
ESPAsyncWebServerライブラリ

ライブラリの入手は以下2つの方法がある
・zipとして入手
・『ライブラリを管理』から検索して入手

2-2.chatGPTにも手伝ってもらって、頑張って作成したコード全体

正直、何度も何度もGPTくんとやりとりしてだんだん正解に近づいていった感じです。伝え方が悪いのかわかりませんが、GPTくんも天才ではないので、結構間違いがあって、対話して対話してとりあえず動作できる状態のコードにたどり着きました。その他みなさんの備忘録も拝見しながら作り上げました。それが以下です。
動作した時は感動ですねー

#include <WiFi.h>//これはESP32ボードを追加すると標準的に付いてくる

//以下2つはzip形式で入れないといけないライブラリ
#include <ESPAsyncWebServer.h>
#include "esp_camera.h"

// カメラのモデルを指定
#define CAMERA_MODEL_WROVER_KIT

// カメラのピンの設定を含む zipで入れたライブラリ内にある
#include "camera_pins.h"

// モーター制御のピンはどの番号のGPIOを使いますか
const int MOTOR_LEFT_PIN_A_1B = 12;  // 左モーター用
const int MOTOR_LEFT_PIN_A_1A = 14;  // 左モーター用
const int MOTOR_RIGHT_PIN_B_1B = 13;   // 右モーター用
const int MOTOR_RIGHT_PIN_B_1A = 15;   // 右モーター用

//WiFiの設定
const char* ssid = "YourSSID";       // SSIDの名前_なんでもOK
const char* password = "12345678";    // SSIDのパスワード_なんでもOK

//サーバー(ESP32側つまりラジコン車体側のポート番号指定 80番が通常)
AsyncWebServer server(80);

//前進、後退、左、右などのモータ始動の命令があった際のモータの動き
//どのpinをHIGHやLOWにするかはモータドライバによるので詳細は後ほど説明
void forward() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, HIGH);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, HIGH);
}
void backward() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, LOW);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, HIGH);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, HIGH);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, LOW);
}
void left() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, LOW);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, HIGH);
}
void right() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, HIGH);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, LOW);
}
void stop() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, LOW);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, LOW);
}
void startCameraServer();//たぶんこれ必要ない 誤記

// ウェブページのためのHTMLコンテンツ これがスマホに表示される
const char html[] =
"<!DOCTYPE html><html lang='ja'><head><meta charset='UTF-8'>\
<meta name='viewport' content='width=device-width, initial-scale=1.0'>\
<style>\
  body { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; height: 100vh; overflow: hidden; margin: 0; }\
  div { order: 1; width: 100%; overflow: hidden; }\
  img#camera-stream { margin-bottom: 12px; }\
  button { padding: 10px 20px; font-size: 16px; background-color: #4CAF50; color: #fff; border: none; border-radius: 5px; cursor: pointer; margin: 12px; }\
  div.controls { order: 2; font-size: 6vw; color: black; text-align: center; width: 80%; margin: 0 auto; padding: 0; }\
</style>\
<title>WiFi_Car Controller</title>\
<script>\
  function updateCameraStream() {\
    var img = document.getElementById('camera-stream');\
    img.src = 'http://' + window.location.hostname + '/stream?cam=esp32-cam&t=' + new Date().getTime();\
  }\
  setInterval(updateCameraStream, 100);\
</script>\
</head>\
<body>\
  <div><img id='camera-stream' src='' style='width:100%; max-width:400px;'></div>\
  <div class='controls'><p>WiFi Car</p>\
    <form method='post' action='/control'>\
      <button type='submit' name='fo'>前進</button><br>\
      <button type='submit' name='le'>左折</button>\
      <button type='submit' name='st'>停止</button>\
      <button type='submit' name='ri'>右折</button><br>\
      <button type='submit' name='ba'>後退</button>\
    </form>\
  </div>\
</body>\
</html>";

//一度限りのsetup事項
void setup() {
    Serial.begin(115200);
    Serial.setDebugOutput(true);
    Serial.println();
    
    // カメラの設定 この設定はよく分からない
    camera_config_t config;
    config.ledc_channel = LEDC_CHANNEL_0;
    config.ledc_timer = LEDC_TIMER_0;
    config.pin_d0 = Y2_GPIO_NUM;
    config.pin_d1 = Y3_GPIO_NUM;
    config.pin_d2 = Y4_GPIO_NUM;
    config.pin_d3 = Y5_GPIO_NUM;
    config.pin_d4 = Y6_GPIO_NUM;
    config.pin_d5 = Y7_GPIO_NUM;
    config.pin_d6 = Y8_GPIO_NUM;
    config.pin_d7 = Y9_GPIO_NUM;
    config.pin_xclk = XCLK_GPIO_NUM;
    config.pin_pclk = PCLK_GPIO_NUM;
    config.pin_vsync = VSYNC_GPIO_NUM;
    config.pin_href = HREF_GPIO_NUM;
    config.pin_sscb_sda = SIOD_GPIO_NUM;
    config.pin_sscb_scl = SIOC_GPIO_NUM;
    config.pin_pwdn = PWDN_GPIO_NUM;
    config.pin_reset = RESET_GPIO_NUM;
    config.xclk_freq_hz = 20000000;
    config.pixel_format = PIXFORMAT_JPEG;

    //画像の品質を決める
    if (psramFound()) {
        config.frame_size = FRAMESIZE_SVGA;
        config.jpeg_quality = 8;
        config.fb_count = 2;
    } else {
        config.frame_size = FRAMESIZE_SVGA;
        config.jpeg_quality = 8;
        config.fb_count = 1;
    }
    
    esp_err_t err = esp_camera_init(&config);

    //エラーがあった場合のお知らせ文
    if (err != ESP_OK) {
        Serial.printf("Camera init failed with error 0x%x", err);
        return;
    }
    
    sensor_t* s = esp_camera_sensor_get();
    s->set_framesize(s, FRAMESIZE_VGA);
    
    // WiFiの設定 今回はラジコン-スマホ間のみの接続でやりたいソフトAPモード
    WiFi.softAP(ssid, password);
    Serial.println("WiFi AP mode configured");
    
    // モーターのピンの設定 モータに動けと電圧をかけるのですべてOUTPUTにする
    pinMode(MOTOR_LEFT_PIN_A_1B, OUTPUT);
    pinMode(MOTOR_LEFT_PIN_A_1A, OUTPUT);
    pinMode(MOTOR_RIGHT_PIN_B_1B, OUTPUT);
    pinMode(MOTOR_RIGHT_PIN_B_1A, OUTPUT);
    
    // ウェブサーバーの開始
    startCameraServer();
    Serial.print("Camera Ready! Access the camera stream at 'http://192.168.4.1'");
}
void startCameraServer() {
    // ウェブサーバーのルート定義
    server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
        request->send_P(200, "text/html", html);
    });
    // カメラストリームへのアクセス
    server.on("/stream", HTTP_GET, [](AsyncWebServerRequest *request) {
        if (request->hasArg("cam")) {
            camera_fb_t *fb = esp_camera_fb_get();
            if (fb) {
                request->send_P(200, "image/jpeg", fb->buf, fb->len);
                esp_camera_fb_return(fb);
            } else {
                request->send(500, "text/plain", "Camera capture failed");
            }
        }
    });
    // モーター制御 スマホから送信されるURLに"ba"があればbackward()関数を実施してモータを後退させる
    server.on("/control", HTTP_POST, [](AsyncWebServerRequest *request) {
        if (request->hasArg("fo")) {
            forward();
        } else if (request->hasArg("ba")) {
            backward();
        } else if (request->hasArg("le")) {
            left();
        } else if (request->hasArg("ri")) {
            right();
        } else if (request->hasArg("st")) {
            stop();
        }
        request->redirect("/");
    });
    // サーバーの開始
    server.begin();
}
void loop() {
    //delay(100);//これは無くてもいいかも
}

上記のコードの概要
ライブラリのインクルード:

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include "esp_camera.h"

ESP32用のWiFiライブラリと、非同期Webサーバーを扱うためのESPAsyncWebServerライブラリ、ESP32のカメラ機能を利用するためのesp_camera.hが含まれています。

ピンとWiFiの設定:

const int MOTOR_LEFT_PIN_A_1B = 12;
// ... 他のモーターのピンの設定

const char* ssid = "YourSSID";
const char* password = "12345678";

モーターの制御に使用するGPIOピンの定義と、WiFi接続の設定が行われています。

モーター制御関数:

void forward();
void backward();
void left();
void right();
void stop();

ラジコンカーのモーターを前進、後退、左折、右折、停止させるための関数が定義されています。これらの関数は、指定されたGPIOピンをHIGHまたはLOWに設定してモーターを制御します。

HTMLコンテンツ:

const char html[] = "...";  // HTMLコンテンツが文字列として定義されています。

ラジコンカーの制御を行うためのWebページのHTMLが定義されています。このHTMLは、スマートフォンで表示され、カメラストリームとボタンを含みます。

セットアップ関数:

void setup() { ... }

Arduinoスケッチのセットアップ部分で、シリアル通信の初期化、カメラの設定、WiFiの設定、モーターのピンモード設定、ウェブサーバーの開始などが行われます。

ループ関数:

void loop() { ... }

Arduinoスケッチのメインループ。このスケッチでは、何も行わないようにコメントアウトされていますが、適切な場面で使用される可能性があります。

カメラストリームとモーター制御のURLハンドリング:

server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { ... });
server.on("/stream", HTTP_GET, [](AsyncWebServerRequest *request) { ... });
server.on("/control", HTTP_POST, [](AsyncWebServerRequest *request) { ... });

Webサーバーがルート、カメラストリーム、およびモーター制御用のURLをハンドリングし、対応するアクション(HTMLの送信、カメラストリームの送信、モーター制御の実行)を実行します。

カメラの初期化:

esp_err_t err = esp_camera_init(&config);

カメラの初期化が行われています。カメラの設定はcamera_config_t構造体で指定されています。

このコードは、WiFiを介してスマートフォンから制御できるラジコンカーを実現するためのもので、カメラストリームも提供されています。

2-3.それぞれのコードの解説

2-3-1.ライブラリのインクルード
#include <WiFi.h>//これはESP32ボードを追加すると標準的に付いてくる

//以下2つはzip形式で入れないといけないライブラリ
#include <ESPAsyncWebServer.h>
#include "esp_camera.h"

// カメラのモデルを指定
#define CAMERA_MODEL_WROVER_KIT

// カメラのピンの設定を含む zipで入れたライブラリ内にある
#include "camera_pins.h"

上記は必要なライブラリのインクルード
特に困ったのが、ESP8266なんかを使うときはWebseverっていうライブラリを使うんだけど、カメラを使用する今回のような場合ESPAsyncWebServerを使用しないと動かず。
これにだいぶはまって、一時は諦めかけた。
esp_camera.hやcamera_pins.hはESPAsyncWebServerライブラリ内にあるので、
ライブラリとしては以下の2つを挿入している。
・WiFi.h(ESP32標準ライブラリ)
・ESPAsyncWebServer.h(Zipよりインクルード)

2-3-2.WiFiの設定、サーバーのポート番号設定
//WiFiの設定
const char* ssid = "YourSSID";       // SSIDの名前_なんでもOK
const char* password = "12345678";    // SSIDのパスワード_なんでもOK

//サーバー(ESP32側つまりラジコン車体側のポート番号指定 80番が通常)
AsyncWebServer server(80);

ここはただの設定、今回ESP基板とスマホで直にやり取りするsoftAPモードを使用するので
SSIDやパスワードは任意の値でいい。適当に付けた。

ESP側はサーバー、スマホ側をクライアントと呼び、サーバー側のデータ入口番号が通常80番ポートのようなので80を設定。Webseverではなく、AsyncWebServerで設定をかける。
ポート番号は何でもいいわけではなくて、次に通常使うのは8080だそう。

2-3-3.モータピンの設定とモータの動きの設定
// モーター制御のピンはどの番号のGPIOを使いますか
const int MOTOR_LEFT_PIN_A_1B = 12;  // 左モーター用
const int MOTOR_LEFT_PIN_A_1A = 14;  // 左モーター用
const int MOTOR_RIGHT_PIN_B_1B = 13;   // 右モーター用
const int MOTOR_RIGHT_PIN_B_1A = 15;   // 右モーター用

//前進、後退、左、右などのモータ始動の命令があった際のモータの動き
//どのpinをHIGHやLOWにするかはモータドライバによるので詳細は後ほど説明
void forward() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, HIGH);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, HIGH);
}
void backward() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, LOW);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, HIGH);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, HIGH);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, LOW);
}
void left() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, LOW);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, HIGH);
}
void right() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, HIGH);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, LOW);
}
void stop() {
    digitalWrite(MOTOR_LEFT_PIN_A_1B, LOW);
    digitalWrite(MOTOR_LEFT_PIN_A_1A, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1B, LOW);
    digitalWrite(MOTOR_RIGHT_PIN_B_1A, LOW);
}

順番が前後するが、このコードでESP基板で使用するピン番号の設定、この設定をしてピン番号に信号を送ればモータが動く。
次にどのように動けばいいかを具体的に決めているのがHiGHとかLOWとか。
今回モータドライバの設定により、モータがどちらに(正転、逆転)回転すれば前進したり、後退したりするのか、その事情に合わせてピン番号にHIGH(5V)もしくはLOW(0V)を送る。L9110S搭載の今回のモータドライバでは正転等になるには下図の表のごとく信号を送らないといけいない。

2-3-4.スマホに表示するブラウザ画面のデザイン(映像と操作ボタン配置)

ウェブページのためのHTMLコンテンツ これがスマホに表示される
IMG_2182.jpeg

const char html[] =
"<!DOCTYPE html><html lang='ja'><head><meta charset='UTF-8'>\
<meta name='viewport' content='width=device-width, initial-scale=1.0'>\
<style>\
  body { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; height: 100vh; overflow: hidden; margin: 0; }\
  div { order: 1; width: 100%; overflow: hidden; }\
  img#camera-stream { margin-bottom: 12px; }\
  button { padding: 10px 20px; font-size: 16px; background-color: #4CAF50; color: #fff; border: none; border-radius: 5px; cursor: pointer; margin: 12px; }\
  div.controls { order: 2; font-size: 6vw; color: black; text-align: center; width: 80%; margin: 0 auto; padding: 0; }\
</style>\
<title>WiFi_Car Controller</title>\
<script>\
  function updateCameraStream() {\
    var img = document.getElementById('camera-stream');\
    img.src = 'http://' + window.location.hostname + '/stream?cam=esp32-cam&t=' + new Date().getTime();\
  }\
  setInterval(updateCameraStream, 100);\
</script>\
</head>\
<body>\
  <div><img id='camera-stream' src='' style='width:100%; max-width:400px;'></div>\
  <div class='controls'><p>WiFi Car</p>\
    <form method='post' action='/control'>\
      <button type='submit' name='fo'>前進</button><br>\
      <button type='submit' name='le'>左折</button>\
      <button type='submit' name='st'>停止</button>\
      <button type='submit' name='ri'>右折</button><br>\
      <button type='submit' name='ba'>後退</button>\
    </form>\
  </div>\
</body>\
</html>";

htmlだけでかなり多くの事を含んでいるので、今回は詳細は省略するが、
大まかには以下の写真の通り、
・上部にカメラからの映像
・下部にラジコン操作部をボタンの形で配置

ここで学んだのが、操作ボタンとしてはinput要素とbutton要素の2つがよくつかわれるみたいだが、button要素の方がボタンのデザインをすることができ、inputのように情報のも送信submitもできるので、ボタンといえばbutton要素を使う方がいい。
paddingとmarginとあるが、paddingaは境界の内側にどれだけ余白をつくるかでmarginは境界の外側で、例えば他の要素との間隔をどれだけとるかを決める。

そのほかに、これはすごく重要で、カメラの画像が滑らかにするには以下のコードでインターバルを小さくすればスムーズになる。

setInterval(updateCameraStream, 100);\

小さくしすぎると更新が多すぎて負荷になってしまうので良くない。今回の場合100で結構スムーズになったので100とした。

2-3-4-1.スマホに画像を映すには

次に以下かなり勉強になったところで、ブラウザに映像を映すのだけど、プログラム上ではどんな仕組みでおこなわれているのか。
【概要】
まず表示したい画像に名前をつます。(ID付与)
その後、img.src で空だったsrcにアドレスを付与し、/streamはESP内の画像を表す命令なので、/streamの入ったURLをsrcに付与
そのURLでESP内にアクセスし画像をリアルタイムで1枚取得し、それを100ms毎に行って
動画の形にしている

【詳細】
HTML内での要素の定義:

HTMLコード内に img 要素があります。この要素は画像の表示を担当しています。id='camera-stream' はこの要素に一意の識別子を与えています。

<div><img id='camera-stream' src='' style='width:100%; max-width:400px;'></div>

JavaScriptによる画像の更新:

scriptタグ内にJavaScriptコードがあります。これは updateCameraStream 関数を定義し、定期的に画像を更新する役割を果たしています。
updateCameraStream 関数では、img 変数を使用して id='camera-stream' で指定されたイメージ要素を取得しています。
img.src に新しい画像のURLがセットされています。

function updateCameraStream() {
  var img = document.getElementById('camera-stream');
  img.src = 'http://' + window.location.hostname + '/stream?cam=esp32-cam&t=' + new Date().getTime();
}

画像の取得:

img.src にセットされたURLは、ESP32からカメラ画像を取得するためのエンドポイントを指しています。
/stream?cam=esp32-cam&t= の後には、カメラのID(esp32-cam)やタイムスタンプがクエリパラメータとして追加されています。これはキャッシュの回避やリアルタイムな画像の取得を可能にします。

img.src = 'http://' + window.location.hostname + '/stream?cam=esp32-cam&t=' + new Date().getTime();

画像の表示:

定期的に更新された画像は、ブラウザ内の img要素で表示されます。style='width:100%; max-width:400px;' によって画像は幅100%で表示され、最大幅は400pxに制限されます。
この流れにより、ESP32からの画像が定期的に取得され、ブラウザ上で表示されます。id='camera-stream' は開発者が定義したHTML上の要素の一意の識別子であり、ESP32内で何かしらの定義がされているわけではない。

画像取得の全体像
HTMLとIDの付与 (コード0):

<div><img id='camera-stream' src='' style='width:100%; max-width:400px;'></div>

画像要素に id='camera-stream' が付与され、このIDがJavaScriptで使用されます。

JavaScriptによる画像の更新 (コード1):

function updateCameraStream() {
    var img = document.getElementById('camera-stream');
    img.src = 'http://' + window.location.hostname + '/stream?cam=esp32-cam&t=' + new Date().getTime();
}
setInterval(updateCameraStream, 100);

JavaScriptが定期的に updateCameraStream 関数を呼び出し、指定された id が 'camera-stream' の画像要素の src 属性に新しい画像のURLを設定しています。このURLには /stream へのリクエストが含まれます。

サーバーサイドでのリクエスト処理 (コード2):

server.on("/stream", HTTP_GET, [](AsyncWebServerRequest *request) {
    if (request->hasArg("cam")) {
        camera_fb_t *fb = esp_camera_fb_get();
        if (fb) {
            request->send_P(200, "image/jpeg", fb->buf, fb->len);
            esp_camera_fb_return(fb);
        } else {
            request->send(500, "text/plain", "Camera capture failed");
        }
    }
});

ESP32のWebサーバーが /stream へのGETリクエストを受け取り、リクエストに "cam" という引数があれば、ESP32のカメラから画像をキャプチャしてHTTPレスポンスとして返します。ブラウザはこのレスポンスを受け取り、画像が更新されます。

このフローにより、ESP32のカメラ画像がリアルタイムにWebページ上に表示される仕組みが構築されています。

1. まず表示したい画像に名前をつます。(ID付与)(コード0)
2. img.src で空だったsrcにアドレスを付与し、/streamはESP内の画像を表す命令なので、/streamの入ったURLをsrcに付与(コード1)
3. /streamにURLが送信され、Getリクエストがあるか待って、リクエストが来てcamという文字があればimage/jpegをブラウザに返す(コード2)そして表示される。

2-3-4-2.どうやってプログラム的にラジコンが動くのか?

ブラウザ内のボタンを押すとどうなる?
以下のような2つのコード(コード0と1)がボタンを押した後、ラジコンが動くまでを担っている。
コード0とコード1の関係性は、コード0がHTMLフォームを定義しており、ボタンが押されたときにどのアクションが実行されるかを指定しています。コード1はそれに対応するサーバーサイドの処理を定義しています。

HTMLフォーム(コード0):

<form method='post' action='/control'>
   <button type='submit' name='fo'>前進</button><br>
   <button type='submit' name='le'>左折</button>
   <button type='submit' name='st'>停止</button>
   <button type='submit' name='ri'>右折</button><br>
   <button type='submit' name='ba'>後退</button>
</form>

このフォームはPOSTメソッドで /control にデータを送信します。ボタンごとに名前が設定されており、それに対応する動作がサーバーサイドで実行されます。
/control はESPでは一般的に使用されるESP内に既に定義された操作に関するアドレスで
ここに来るURLを見てラジコンをどう動かすか決める。

サーバーサイドの処理(コード1):

server.on("/control", HTTP_POST, [](AsyncWebServerRequest *request) {
    if (request->hasArg("fo")) {
        forward();
    } else if (request->hasArg("ba")) {
        backward();
    } else if (request->hasArg("le")) {
        left();
    } else if (request->hasArg("ri")) {
        right();
    } else if (request->hasArg("st")) {
        stop();
    }
    request->redirect("/");
});

このサーバーサイドの処理は、 /control へのPOSTリクエストを待ち受け、リクエスト内に含まれる引数に応じてモーター制御関数を呼び出しています。例えば、"fo"(前進ボタンが押された場合)なら forward() 関数が呼ばれます。

/controlはサーバーサイドのエンドポイント(エンドポイントとは、ウェブアプリケーション内で特定の機能や処理を担当するURLのこと)であり、ここにデータが送信されると、サーバーサイドの処理が実行されます。

具体的には、HTMLフォーム内のaction='/control'が、フォームが送信されたときにデータを /control に対して送信するように指定しています。このデータ送信は、フォーム内のボタンが押されたときに発生します。各ボタンにはname属性があり、それぞれ異なる値を持っています。

例えば、前進ボタンは前進となっており、nameが'fo'です。このボタンが押されると、サーバーサイドのコードが受信したデータ内に'fo'という名前の引数が存在するかを確認し、存在すれば前進に関する処理を実行します。

/controlはサーバーサイドで待ち受けているエンドポイントであり、ESP32のウェブサーバーがこのエンドポイントに対するリクエストを処理します。このようにして、ESP32上で動作するウェブアプリケーションに対して、ユーザーがボタンを押すことで特定の操作(前進、後退など)を実行できるようになっています。

このように、コード0で指定されたフォームが送信されると、サーバーサイドのコード1が受け取り、ボタンに対応した動作を実行します。それぞれのボタンが異なる名前を持っており、それがサーバーサイドで処理されるトリガーとなっています。

server.on("/control", HTTP_POST, [](AsyncWebServerRequest *request)

"/control"にpostが来るのを待って、もしbaなど来れば、server.onしてbakcward()を実行してね

request->redirect("/");

実行後は、トップページ("/")に戻ってね

2-3-4-3.その他きになること

RAMが利用可能かどうかを確認し、利用可能な場合と利用できない場合でカメラのフレームサイズ、JPEG品質、およびフレームバッファの数を設定。
FRAMESIZE_SVGAを変えると品質を変えることができるので、もしかすると低画質にすれば
処理が楽になってスムーズになる可能性あり

if (psramFound()) {
    config.frame_size = FRAMESIZE_SVGA;
    config.jpeg_quality = 8;
    config.fb_count = 2;
} else {
    config.frame_size = FRAMESIZE_SVGA;
    config.jpeg_quality = 8;
    config.fb_count = 1;
}

もしスマホ-ESP間での通信ではなく、間にホームルータを介する通信の場合
以下のソフトAPモードではなく、コードを少し修正しないといけない。

  WiFi.softAP(ssid, password);
  Serial.println("WiFi AP mode configured");

よく見かける以下のコードがvoid setup関数内に必要

// Connect to Wi-Fi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  Serial.println("Connected to WiFi");

また、ホームルーターのSSIDやパスワードがコードの始めに必要

const char *ssid = "YourRouterSSID";
const char *password = "YourRouterPassword";

server.begin(); と server.on() の順序に関して、動作に不具合がない理由は、AsyncWebServer ライブラリが非同期(非ブロッキング)で動作するためです。このライブラリはイベント駆動型で、コールバック関数がイベントが発生した際に呼び出されます。

server.begin(); はサーバーを開始するメソッドですが、実際には非同期的にサーバーが待ち受けを始め、その後に受信されたリクエストに対して登録されたコールバック関数が呼び出されます。ですので、server.on() でルーティングを設定する前に server.begin(); を呼んでも、リクエストがくるまでコールバックが実行されないため、特に問題が発生しません。

ただし、プログラムの読みやすさや理解しやすさの観点から、通常は server.begin(); を server.on() の前に配置することが推奨されます。これにより、セットアップの最後にサーバーを開始するという流れが直感的になります。ただし、技術的な観点からは問題ない動作と言えます。

#3.参考になった動画、サイトなど
海外の動画
ソフトAPにてESP32操作

3.作製してみて、、、

今回、結構、僕的には集大成でした。一度諦めかけたのですが、他の方の素晴らしい記事やchatGPTくんのおかげでなんとかやり終えましたね。
まだまだブラッシュアップすべきとこがあるのは十分承知です。
できる方からみれば大したことないと思いますが、初心者にはハードルが高かったですね。
コード内もすべて理解はできておらず、なんとなくなとこも多いので、それは今後の課題です。本当はpicとか使って、便利はライブラリ無し、もしくは自作ライブラリでできるといいですが、まだまだ技術レベルが足りません。

何か作りたいけど、Lチカ飽きたしって方には良い題材、助けになることを願っています。
僕もいろんな方の記事に助けられましたから。

次は何しようかなー

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4