HaLake Magazine

コワーキングスペースHaLakeの技術情報発信サイト!IoT,AR,VRなど最新技術情報をお届け!

走行型ロボットをESP32でブラウザから制御

f:id:takumishinoda:20190915142928p:plain

今回は、タミヤのカムプログラムロボットという走行型ロボットにESP32を搭載してブラウザから制御する方法を記述していきます。
カムプログラムロボットというのはカムと呼ばれるスティックで物理的にプログラムしてロボットを制御できる、プログラミングの基礎を学べるような製品です。
以下のリンクが公式ページになっています。

www.tamiya.com

そんなカムでプログラミング制御できるロボットを、今回はESP32を用いてパソコンでプログラム・制御していこうと思います。

最終目標

・ESP32で走行型ロボットを制御
・ブラウザから制御できるようにする

学べること

・モータードライバの使い方・注意点
・簡単にESP32でWEBサーバを建てる

開発前提

・半田付けができる
・Platformioを使ったことがある
・ESP32にプログラムを書き込んだことがある

使ったもの

・カムプログラムロボット ・ESP32(node MCU

www.amazon.co.jp

・モータドライバ www.switch-science.com ・はんだセット
・ブレッドボード
・単三2本用バッテリーボックス ・単三3本用バッテリーボック

開発順序

  1. 戦車の物理的改造・回路作り
  2. ESP32のプログラム作り

1. 戦車の物理的改造・回路作り

f:id:takumishinoda:20190915143048j:plain

まずは回路を作っていきます。
カムプログラムロボットには二個のモータが搭載されているので、モータの線が合計で4本あります。
また、標準で単三電池一本が入るバッテリーケースがありますが今回は使用せずバッテリーケースは別途、単三2本用と単三3本用を使用します。 それを踏まえた上で、回路は以下のようになります。

f:id:takumishinoda:20190915102851j:plain
基本回路

まずモータドライバの説明で、この回路ではモータ電源を3V電源を用意しています。
キットではバッテリーボックスが一本用で1.5V電源でしたが、モータドライバのによる電圧降下問題を解決するために3Vに強化しました。

次にバッテリーボックスについてですが、今回はバッテリーボックスをわざわざ二つ使用しています。
この理由は、もしESP32とモータ供給を一つのバッテリーボックスから行ってしまうとモータを正転と逆転に切り替えた際、バッテリーから供給される電圧が一瞬著しく降下してようで、ESP32側でBrownout Detect(電圧低下検知)が働き再起動してしまいます。
なので電源を二つに分けることでこの問題に対応しています。

ESP32の電源は今回3本の単三電池(4.5V)でまかないますが、ESP32の動作電圧は2.7~3.3VなのでNodeMCUの3.3Vピンに繋ぐと破損してしまう可能性があります。
そこで、NodeMCUに用意されている5Vピンを今回は使用しています。
このピンはNodeMCU内蔵のレギュレータに繋がっているため、内蔵回路で3.3Vにしてくれます。
また、カムプログラムロボットにはスイッチがあるのでこのスイッチを単三3本電池ボックスのプラスまたはマイナスの線とブレッドボードの間に挟むことで、ESP32の電源を管理できるようになります。

次に制御線についてで、今回はモータ1をESP32の16・17ピンでモータ2を18・19ピンで制御していきます。
モータはこれらのピンの状態で制御され、デジタル信号(電流のON・OFF)で制御されます。

これで回路作成はできたので、次はプログラムの作成になります。

2. ESP32のプログラム作り

ESP32のプログラムではWEBサーバーを立てていきます。
ESP32でWEBサーバを立ち上げる方法は、TCP接続をしてHTTPプロトコルを自分で処理していく方法と簡単にWEBサーバを立てるライブラリを使用し、簡単に実装する方法があります。
今回は後者の方法で作成しましたが、利用したのは個人的に作成していたライブラリを使用していきます。
リポジトリリンクは以下に貼っておきますが、個人で作成しているものなので仕様が変わる可能性があるのでご了承ください。

github.com

このライブラリをgithubから直接Platdformioで利用するには、platformio.iniファイルに以下を追記します。

lib_deps =
  https://github.com/TakumiShinoda/Esp32_WebServerObject

それでは早速コードを見ていきましょう。

#include <Arduino.h>
#include <ServerObject.h>
#include <WiFiConnection.h>

// ここは制作環境に応じて変更
#define MOTOR_A_PIN1 16
#define MOTOR_A_PIN2 17
#define MOTOR_B_PIN1 19
#define MOTOR_B_PIN2 18

#define STOP 0
#define ROTATE_NORMAL 1
#define ROTATE_REVERSE 2

#define TURN_STOP 0
#define TURN_LEFT 1
#define TURN_RIGHT 2

ServerObject server = ServerObject(); // サーバのインスタンス

void leftWheel(bool pin1, bool pin2){ // 左の車輪制御
  digitalWrite(MOTOR_A_PIN1, pin1);
  digitalWrite(MOTOR_A_PIN2, pin2);
}

void rightWheel(bool pin1, bool pin2){ // 右の車輪制御
  digitalWrite(MOTOR_B_PIN1, pin1);
  digitalWrite(MOTOR_B_PIN2, pin2);
}

void wheel(uint8_t left, uint8_t right){ // 車輪制御(左右)
  if(left == ROTATE_NORMAL) leftWheel(true, false);
  else if(left == ROTATE_REVERSE) leftWheel(false, true);

  if(right == ROTATE_NORMAL) rightWheel(true, false);
  else if(right == ROTATE_REVERSE) rightWheel(false, true);
}

void stop(){ // 停止
  leftWheel(false, false);
  rightWheel(false, false);
}

void turnLeft(){ wheel(false, true); } // 左に旋回
void turnRight(){ wheel(true, false); } // 右に旋回

void tankCallback(ChainArray queries, ChainArray request, String *response, WiFiClient *client){ // 'IP/tank'にアクセスされた時のコールバック
  client->println(*(response));
  client->stop();

  if(queries.exist("turn")){ // クエリにturnがあった時
    uint8_t turn = queries.get("turn").toInt();

    if(turn == TURN_LEFT) wheel(ROTATE_REVERSE, ROTATE_NORMAL);
    else if(turn == TURN_RIGHT) wheel(ROTATE_NORMAL, ROTATE_REVERSE);

    if(queries.exist("turn_delay")) delay(queries.get("turn_delay").toInt());
    else delay(1000);
    stop();
  }

  if(queries.exist("forward")){ // クエリにforwardがあった時
    uint8_t forward = queries.get("forward").toInt();

    if(forward == ROTATE_NORMAL) wheel(ROTATE_NORMAL, ROTATE_NORMAL);
    else if(forward == ROTATE_REVERSE) wheel(ROTATE_REVERSE, ROTATE_REVERSE);

    if(queries.exist("forward_delay")) delay(queries.get("forward_delay").toInt());
    else delay(1000);
    stop();
  }
}

void setup(){
  ResponseHandler tankResponse("hoge", &tankCallback); // レスポンスを生成

  Serial.begin(115200);
  if(connectAP("ssid", "pass")) Serial.println("Cannot connect to AP."); // WiFiに接続

  pinMode(MOTOR_A_PIN1, OUTPUT);
  pinMode(MOTOR_A_PIN2, OUTPUT);
  pinMode(MOTOR_B_PIN1, OUTPUT);
  pinMode(MOTOR_B_PIN2, OUTPUT);

  server.addServer(80); // WEBサーバのポートを追加
  server.setResponse(80, "/tank", &tankResponse, METHOD_GET); // ポート・パス・レスポンス内容を追加

  server.openAllServers(); // サーバー開始
}

void loop(){
  server.requestHandle(80); // アクセス確認(loop内にdelayは入れない)
} 

まずマクロで定義されているモータ制御ピンの指定は、制作環境によってモータとモータドライバの接続の関係で逆になっている可能性があるので、それぞれピンの指定を入れ変える必要があるかもしれないので注意が必要です。

今回利用したライブラリではクエリ文字列を簡単に扱えるようにしているので、戦車をクエリ文字列で制御できるようにしました。
クエリ文字列のforwardパラメータに1を渡せば前進、2を渡せば後進するにようになっています。
また、turnパラメータに1を渡せば左に旋回、2を渡せば右旋回するようになっています。
さらにデフォルトではそれぞれ1秒間動作を続けますが、動作時間を指定するために、forward_delayturn_delayパラメータでミリ秒で指定すればその秒数分動作を実行します。
動作は、コールバック関数を見てもらうとわかるように、旋回処理が先に来ていてそのあとに前進後進処理がされるのでクエリ文字列に同時に処理を記述すると旋回後に前後進します。
よって制御方法としては以下のようになります。

例1: IP/tank?forward=1&turn=1&turn_delay=3000 => 左に3秒回った後に前に1秒進む
例2: IP/tank?forward=2&turn=2&forward_delay=3000&turn_delay=1500 => 右に1.5秒回った後に後ろに3秒進む

f:id:takumishinoda:20190915143815g:plain
例2の動作


おまけ

f:id:takumishinoda:20190915160322p:plain

いちいちURLを編集するのは面倒なので、ブラウザから簡単に遊べるようなHTMLを作成しました。
以下のソースを適当なフォルダに保存してブラウザで開くと簡単に操作できます。

<html>
  <head>
    <title>Tank Test</title>
  </head>
  <body>
    <h1>Tank Test</h1>
    <hr>
    <label>IPアドレス</label>
    <br>
    <input type="text" value="192.168" readonly>
    <span>.</span>
    <input id="ip1" type="number" value=0>
    <span>.</span>
    <input id="ip2" type="number" value=0>
    <br>
    <br>
    <label>前後進</label>
    <input id="forward" type="range" min="0" max="2" value="1" oninput="updateDisplay()">
    <input id="forward_display" type="text">
    <br>
    <label>全後進時間</label>
    <input id="forward_delay" type="number" value="1000" required>
    <br>
    <br>
    <label>旋回</label>
    <input id="turn" type="range" min="0" max="2" value="1" oninput="updateDisplay()">
    <input id="turn_display" type="text">
    <br>
    <label>旋回時間</label>
    <input id="turn_delay" type="number" value="1000" required>
    <br>
    <br>
    <button onClick="exec()">実行</button>
    <span id="status"></span>

    <script>
      let isExec = false

      function updateDisplay(){
        let forward = document.getElementById('forward').value
        let turn = document.getElementById('turn').value

        if(forward == 0) document.getElementById('forward_display').value = "停止"
        else if(forward == 1) document.getElementById('forward_display').value = "前進"
        else if(forward == 2) document.getElementById('forward_display').value = "後進"

        if(turn == 0) document.getElementById('turn_display').value = "停止"
        else if(turn == 1) document.getElementById('turn_display').value = "左旋回"
        else if(turn == 2) document.getElementById('turn_display').value = "右旋回"
      }

      function runFetch(url, timeout){
        const controller = new AbortController()
        const signal = controller.signal

        return new Promise((res, rej) => {
          setTimeout(() => { controller.abort(); rej("Time out."); }, timeout)
          fetch(url, { signal: signal })
            .then(() => {res()})
            .catch((err) => {
              rej(err)
            })
        })
      }

      async function exec(){
        if(isExec) return 
        else{
          isExec = true
          document.getElementById('status').textContent = "実行中"
        }

        let forward = document.getElementById('forward').value
        let turn = document.getElementById('turn').value
        let forward_delay = document.getElementById('forward_delay').value
        let turn_delay = document.getElementById('turn_delay').value
        let ip1 = document.getElementById('ip1').value
        let ip2 = document.getElementById('ip2').value
        let ip = "http://192.168." + ip1 + "." + ip2
        let url = ip + "/tank?forward=" + forward + "&turn=" + turn + "&forward_delay=" + forward_delay + "&turn_delay=" + turn_delay

        if(forward != 0 && forward != 1 && forward != 2){ alert("予期しない値です。"); return; }
        if(forward != 0 && turn != 1 && turn != 2){ alert("予期しない値です。"); return; }
        if(isNaN(ip1) || isNaN(ip2)){ alert("予期しないIPです。"); return; }

        try{ await runFetch(url, 3000) }
        catch(err){ alert(err) }
        isExec = false
        document.getElementById('status').textContent = ""
      }

      updateDisplay()
    </script>
  </body>
</html> 

まとめ

・モータドライバを使用する際はESP32と電源を別にしないとマイコンが再起動する
githubからライブラリを直接使うにはplatformio.inilib_depsに追記する