ESP-WROOM-02のWEBサーバーで電動サーボ雲台

ESP-WROOM-02の汎用I/Oを使い、httpリクエストでもって小型電動雲台を構成する2つのサーボを駆動できるようになった。当初はArduino ADK + WROOM-02でやっていたことを、WROOM-02単独で実現できたわけで、ずいぶん安上がりになった。

回路構成

WROOM-02単独でのプログラミングを行うために作った回路を基にして、サーボを駆動するためのトランジスタなどを追加したり余計なものを整理したりした。せっかくなので、水魚堂さんの回路図エディタを使って描き直してみた。

wroom02-servo1

回路図では、右側にサーボ用の出力端子(実際にはピンポスト)を表すR-SERVO(回転用)とT-SERVO(ティルト用)を配置しており、R-SERVOの制御はGPIO5、T-SERVOの制御はGPIO4の出力で行う。各GPIO出力は+5Vのオン/オフ用のスイッチとして動作するトランジスタに接続している。前回の回路から大きく変わったのはサーボ用のトランジスタの追加くらいなので、簡単に説明書きを。

2SC1815GRトランジスタだなんて細かい部品は使いたくなったのだけど、安上がりなスイッチング素子として利用した。トランジスタのベースを+3.3Vに接続しているから、GPIOがH(+3.3V)ならばベース-エミッタ間には電圧がかからないのでコレクタには電流は流れない。したがってサーボのSIGNALの電圧は、プルアップ抵抗を介して+5V電源に接続されているからHレベルになる。一方GPIOがLのときにはエミッタが接地されていることになり、いわばコレクタ-エミッタ間は導通する。なので、+5V電源から10KΩのプルアップ抵抗を介してトランジスタに電流が流れ込むので、SIGNALの電圧はLレベルとなる。

PCからプログラムを送り込み(USB-FT231X経由)、フラッシュメモリに格納するには、GPIO0につながるMODE端子をGNDに接続してRESETを押してUARTダウンロードモードとしてWROOM-02を起動する。電源オン時からフラッシュメモリの内容を実行するときには、MODE端子を+3.3Vに接続しておく。まだブレッドボードの上にこしらえているので、MODE端子の接続変えは、ジャンパワイヤの差し直しで実現している(一番楽)。

ESP-HTTPD_SERVO1上記の回路をブレッドボードの上に載せるとこんな具合になっており、思っていたより混み合ってしまった。

この写真のブレッドボードは、左右端にある電源ラインが独立している。つまり二系統の電源を実現できる便利なボードなのだけど、当初そのことに気が付かずに組んでしまい、まったく動作しなくて首をかしげてしまった。最初にテスターで調べておくべきでした。

ソフトウェア

ほぼ同じ回路構成を使って、httpリクエストでLEDをチカチカさせたときと同様に、ESP8266 Communityが用意してくれているWEBサーバーライブラリ(ESP8266WebServer)を利用し、WROOM-02のフラッシュメモリにwebサーバーの実装を載せる。今回は、httpリクエストに応じてサーボを駆動するだけの違いなのだが、若干長くなった。

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <Ticker.h>

/**
 * ESP-WROOM-2 driving dual RC-Servos.
 * Copyright (c) 2016 okiraku-camera.tokyo.
 */

const char* ssid = "ESP8266AP2";
const char* password = "password";
ESP8266WebServer server(80);

// gpio  
#define RED_LED 12 
#define ROTATE_SERVO  5
#define TILT_SERVO  4

typedef struct {
  int angle;
  int current_angle;
  int servo_pin;
  byte finished;
  int servo_counter; 
} servo_t;

servo_t r_servo = {90, 90, ROTATE_SERVO,  0, 10};
servo_t t_servo = {90, 90, TILT_SERVO,  0, 10};

Ticker ticker;
long tick_counter = 0;

enum  led_style {led_off = 0, led_on = 1, slow_blink = 2, fast_blink = 3 } ;
volatile enum led_style red_led = led_off;

// generate driving pulse.
//  ANGLE  HIGE_PULSE_WIDTH
//  0      2400us
//  90     1500us
//  180    600us
void drive_servo(int angle, int servo_pin) {
  long pulse_width = (180 - angle) * 10 + 600;
  digitalWrite(servo_pin, HIGH);
  delayMicroseconds(pulse_width);
  digitalWrite(servo_pin, LOW);
}

void servo_func(servo_t* p) {
  if (p->servo_counter > 0) {
    p->servo_counter--;
    drive_servo(p->angle, p->servo_pin);
    return;  
  }
  if (p->angle < p->current_angle)
    p->current_angle--;
  else if (p->angle > p->current_angle)
    p->current_angle++;
  else 
    p->finished = 1;
  drive_servo(p->current_angle, p->servo_pin);
}

void led_func() {
  static byte led_state = 0;
  if (red_led == led_off)
    led_state = 0;
  else if  (red_led == led_on)
    led_state = 1;
  else if ((red_led == slow_blink && (tick_counter % 100 == 0)) || 
      (red_led == fast_blink && (tick_counter % 30 == 0)))
    led_state ^= 1;    
  digitalWrite(RED_LED, led_state);
}

void ticker_func() {
  tick_counter++;
  led_func();
  servo_func(&r_servo);
  servo_func(&t_servo);
}

static const char* cpResponseMenu = 
    "<HTML><BODY><h1>ESP_HTTPD_SERVO1</h1><TABLE style='font-size:40px;width:100%'>"
    "<tr><td width='40%'><a href=/LED?cmd=on&menu=1>LED on</a></td><td><a href=/LED?cmd=blink2&menu=1>LED blink2</a></td></tr>"
    "<tr><td><a href=/SERVO?RA=0&TA=0&menu=1>0/0</a></td><td><a href=/SERVO?RA2=0&TA2=0&menu=1>FAST 0/0</a></td></tr>"
    "<tr><td><a href=/SERVO?RA=180&TA=180&menu=1>180/180</a></td><td><a href=/SERVO?RA2=180&TA2=180&menu=1>FAST 180/180</a></td></tr>"
    "<tr><td colspan='2'><a href=/SERVO?HOME=1&menu=1>HOME</a></td></tr>"
    "</TABLE></BODY></HTML>\r\n";
void handleMenu() {
  server.send(200, "text/html", cpResponseMenu);
}
static const char* cpResponse200 = "<HTML><BODY>OK</BODY></HTML>\r\n";

void handleLed() {
  String cmd = server.arg("cmd");
  Serial.println("handleLed() cmd=" + cmd);
  if (cmd == "on")  
    red_led = led_on;
  else if (cmd == "blink1")
    red_led = slow_blink;
  else if (cmd == "blink2")
    red_led = fast_blink;
  else
    red_led = led_off;
  server.send(200, "text/html", server.hasArg("menu") ? cpResponseMenu : cpResponse200);
}

void setup_servo(servo_t* p, int angle, bool immediately) {
  if (angle < 5)
    angle = 5;
  else if (angle > 175)
    angle = 175;  
  p->angle = angle;
  if (p->angle == p->current_angle) {
    p->finished = 1;
    return;
  }
  p->finished = 0;
  if (immediately) {
    p->current_angle = p->angle;
    p->servo_counter = 1;
  }
}

void handleServo() {
  if (server.hasArg("HOME")) {
    setup_servo(&r_servo, 90, true);
    setup_servo(&t_servo, 90, true);
  } else {
    int i;
    for(i = 0; i < server.args(); i++) {
      String key = server.argName(i);
      if (key.startsWith("RA") || key.startsWith("TA") || key.startsWith("RR")|| key.startsWith("TR")) {
        servo_t* p = key.startsWith("R") ? &r_servo : &t_servo;
        String value = server.arg((const char*)key.c_str());
        // Serial.println(String("DEBUG: handleServo() key = ") + key + String(", value = ") + value );
        int angle = atoi(value.c_str());                        
        if (key == "RR" || key == "TR") // relative.
          angle += p->current_angle;
        setup_servo(p, angle, key.endsWith("2"));          
      }
    }          
  }

  // wait finished. max 3 secs.
  int i;
  for( i = 0; i < 3000; i++) {
    if (r_servo.finished && t_servo.finished)
      break;
    delay(1);    
  }
  server.send(200, "text/html", server.hasArg("menu") ? cpResponseMenu : cpResponse200);
  Serial.print("DEBUG: handleServo() completed\r\n");
}

void setup() {
  Serial.begin(115200);
  pinMode(RED_LED, OUTPUT);
  digitalWrite(RED_LED, 0);
  pinMode(ROTATE_SERVO, OUTPUT);
  pinMode(TILT_SERVO, OUTPUT);
  digitalWrite(ROTATE_SERVO, 0);
  digitalWrite(TILT_SERVO, 0);

  WiFi.softAP(ssid, password);
  IPAddress ip = WiFi.softAPIP();
  Serial.println("");
  Serial.println(ssid + String(" starts..")); 
  Serial.print("this AP : "); 
  Serial.println(ip);
  ticker.attach_ms(12, ticker_func);
  server.on("/LED", handleLed);
  server.on("/SERVO", handleServo);
  server.on("/MENU", handleMenu);
  server.begin();
  Serial.println("HTTP server started");
  red_led = slow_blink;
}
void loop() {
  server.handleClient();
  delay(1);
}
Tickerライブラリ

今回もTickerライブラリによる定周期呼び出しに依存しており、呼び出し周期はサーボに与える駆動パルスの周期を意識して12msecとした( setup()内の ticker.attach_ms(12, ticker_func); )。10~20msec程度の範囲で、動きがよさそうな値を選択すればよいようである。定周期に呼び出されるvoid ticker_func()では、グローバルカウンタのインクリメントの後で、

led_func();
servo_func(&r_servo);
servo_func(&t_servo);

という3つの関数を呼び出しているが、おのおのでLED、回転用サーボ、ティルト用サーボを駆動している。各関数内であまり時間がかかると、ticker_func() が呼ばれるインターバルを超えてしまうと予定通りに動かないことになるので、注意が必要。

servo_tとservo_func, drive_servo

このプログラムでは、servo_func()の定期的な呼び出しと、回転用とティルト用サーボの独立した駆動指示により、雲台の回転と傾斜が見た目同時に行われることを意図している。

servo_tは、サーボの動きをつかさどる構造体で、目標角度(angle)、現在角度(current_angle)、IOピン(servo_pin)といったメンバをもつ。この構造体の変数 r_servoとt_servoの初期値には、電源オン時またはリセット時にホーム位置(いずれも90度)を指定している。

drive_servo()は、指定の角度に応じたサーボ駆動用HパルスをdelayMicroseconds()を使って作っている。いちおう、ホーム位置(中立位置=90度)を1500μsecと見なして、

long pulse_width = (180 – angle) * 10 + 600;

によってパルス幅を得ているが、RCサーボの種類によっては微妙な調整が必要なのかもしれない。

servo_func()ticker_func()から一定の周期ごとに呼び出され、現在角度と目標角度が異なるとき、drive_servo()を呼び出して1度ずつサーボに駆動パルスを与える。
なお、servo_t.servo_counterが 1以上の場合、即座に目標角度を表す駆動パルスを与えるようにしており、ホーム位置移動や高速動作指定時に用いている。
目標角度に到達するとそのことを表す servo_t.finished = 1とするが、その後の呼び出し時もdrive_servo(); の呼び出しを継続し、サーボに対して現在の角度を維持させようとしている。

httpd関係

httpdとしての諸設定は前回のLEDチカチカ時と同じような具合になっており、setup()内での、

server.on(“/LED”, handleLed);
server.on(“/SERVO”, handleServo);
server.on(“/MENU”, handleMenu);
server.begin();

により、このhttpdが処理すべき3つのURIを指定し、サーバー動作を開始させている。動作についてはおのおのの handleXXXX()を参照。

サーボを駆動するためのURI(/SERVO)は、以下のようなパラメータをとる。各パラメータの1文字目が”R”の場合は回転用サーボへの指示、”T”の場合がティルト用サーボへの指示となっている。

  • /SERVO?RA=abs_angle&TA=abs_angle
    通常速、絶対角度指定
  • /SERVO?RA2=abs_angle&TA2=abs_angle
    高速、絶対角度指定
  • /SERVO?RR=relative_angle&TR=relative_angle
    通常速、相対角度指定
  • /SERVO?RR2=relative_angle&TR2=relative_angle
    高速、相対角度指定
  • /SERVO?HOME=any_value
    ホーム位置指定
  • (abs_angle=0~180, relative_angle=-90~+90)

なお、”/MENU”は、サーボの動きをテストするために用意したURIで、cpResponseMenu の内容をリクエストに対する応答としてブラウザに返す。ブラウザに表示されるリンクをクリックし、2つのサーボが意図とおりに動くかどうかを確認するためのもので、スマホから呼んでみると以下のような表示になる。

ESP_HTTPD_SERVO1

“0/0″はRA=0&TA=0、180/180はRA=180&TA=180に対応している。”FAST”とあるものは、RA2、TA2を使う。この段階では、わざわざHTML FORMを作るほどでもない。

なお、リクエストパラメータに “menu=”が含まれている場合、各リンクによるサーボの駆動後にcpResponse200ではなくcpResponseMenuを応答し、画面の表示を維持する。

/MENUを使ったサーボの動作を動画にしてみた。てっぺんには、AIR A01を載せている。高速で動かすと、カメラのぐらつきが気になる。回転用サーボの軸の上に、都合のいいスペーサーを挟むと解決できると思うのだけど。

次の課題

Arduinoを使わずにESP-WROOM-02単独で、wifi経由でコマンドをもらってサーボを動かすという宿題は終わったので、次の課題はAIR A01用の撮影プログラムのライブビュー上の操作により、今回構成したwifiサーボ雲台を動かす、ということになる。現状でも、AIR A01用(に限らず、O.I.Shareなどのカメラリモコン用)のスマホと、雲台を動かすスマホを分けて2台使えばカメラと雲台の遠隔操作は可能。

以前はAIR A01用のアプリを動かすスマホをUSBケーブルでArduino ADKに接続しアプリ内からArduinoと通信することで、Arduino ADK内のプログラムによって雲台を制御した(ブログに載せたのはステッピングモーター+サーボという構成までだったが)。この構成では、雲台とスマホをほぼ同じ場所に置いておく必要があって実際の使用にあたってちょっと具合が悪い。そのため、wifiで制御できる雲台をこしらえたわけである。
次は、スマホからAIR A01と通信しつつ同じスマホをUSBでArduino ADK+WROOM-02に接続し、そこから雲台側のWROOM-02にhttpリクエストを投げるような構成とする予定。