LoginSignup
12
5

More than 1 year has passed since last update.

THETAプラグイン×M5Stack:QRコードで画像シェア+シリアルリモコン

Last updated at Posted at 2018-12-21

この記事は M5Stack Advent Calendar 2018 の21日目の記事です。

こんなことをしてみました。
(以下動画は16秒程度のショートバージョンです。記事中に2分の詳細説明動画もあります。)

はじめに

リコーの @KA-2 です。
弊社ではRICOH THETAという全周囲360度撮れるカメラを出しています。
このカメラの最新機種(2018年12月現在)RICOH THETA Vは、Androidで動いています。
Androidアプリを作る感覚でTHETAをカスタマイズすることもでき、そのカスタマイズ機能を「プラグイン」と呼んでいます(詳細は本記事の末尾を参照)。

このRICOH THETA、こんな感じの集合写真が得意なことの一つでもあるのですが、このURLをメールなりメッセンジャーなりで多人数に送るのが少々手間です。パーティー的なシーンで初見の方が多いとなおさらですね。
こんなシーンでは、「ここにTHETA置いとくから勝手にデータ持ってって」としたいところです。
これをTHETAプラグインとM5Stackで目指します。

01_M5_serial_remote_QR.jpg

実現方法

THETA V の無線LAN接続について

実現方法を検討する上で知っておく必要がある前提条件を説明しておきます。

  • ダイレクトモード
    THETA Vより前のTHETAとも同じ接続方法です。THETAがアクセスポイントとなり、スマートフォン等の機器を接続します。
    接続したスマートフォン等からTHETA内部の画像データへ直接アクセスすることができます。
    同時接続可能な台数は1台限定です。

  • クライアントモード(詳しくはこちらこちらへ 動画もあります)
    THETA Vから追加された接続方法です。ルータなどのアクセスポイントへTHETAを接続します。
    同じルータに接続されているスマートフォン等からTHETA内部の画像データに直接アクセスすることができますが、通常の状態では認証(予めTHETAに設定しておいたパスワード)が必要になります。
    同時アクセスが可能な台数は、ルータの能力によります(THETA側で制限はかかっていませんが、あまり負荷が高くなるようなことは避けてください)。
    ルータが外部ネットワーク(インターネット)とも接続されている場合、こちらのようなプラグインを応用すると、クラウドやSNSなどに画像データをアップロードすることも考えられます。ライブ配信の事例もあります。

画像データ受け渡し方法

上記を踏まえると、画像受け渡し方法は以下の3通りが考えられます。

  1. THETA Vはダイレクトモードで、カメラ内部の画像を直接ダウンロードしてもらう。
  2. THETA Vはクライアントモードで、カメラ内部の画像を直接ダウンロードしてもらう。
  3. THETA Vはクライアントモードで、外部ネットワークを介しクラウド等に画像をアップロードし、アップロード先の画像をダウンロードしてもらう。

それぞれについて吟味してみましょう。

「1.」について
実現が最も容易です。使う機器も少なく済みます。
ダウンロードを一人づつ行って貰うところがデメリットです。

「2.」について
パスワード認証をTHETAアプリを使っていない方にもクリアしてもらうのが厄介です。ルータが割り当てるIPアドレスが毎回変わるあたりも厄介です。
しかし、これらの厄介ごとは「THETAプラグインでOculus Goブラウザから直接THETA内のファイルを見る」という記事でクリアされています。お、なんかできそう・・・

「3.」について
「外部ネットワークに接続されているルータ」は「スマホでテザリング」でもよいですね。
アップロード先をどうするか、それが大問題です。
アップロード先の認証をクリアするための調べ物が増えそう。
一方でAdvent Calendar の締め切り迫る! うぅぅぅぅ(涙

と、いうわけで今回どうする?
できる/できない ならどれもできそーなのですが、締め切り都合で 「1.」にします!
「2.」「3.」については、RICOH THETAプラグイン開発者コミュニティの活動の中で追々ふぉろーしようと思います。

THETA V - M5Stak 間の通信方法

THETA V は画像データ受け渡しのために無線LANを使ってしまいます。
THETA V側の残る通信手段はBLEかUSB Hostです。

BLEも試してみたいのですが時間的余裕がありません。
USB Hostは既に、「シリアル通信でGPS/GNSSモジュールと連携した記事」の記事でデータの通り道まで作っています。これでいきましょう。
ただし、「THETA Vを開発者モードにした人しか使えない制限」がある点にはご注意ください。

シリアル通信の中身をどうする?

シリアル通信でTHETAを操るためのコマンド体系を独自に考えるのが面倒です。
一方で、THETAのwebAPIは、概ねASCIIコードだけで成立しているコマンド体系です。シリアル通信でもそれを利用してしまいましょう。

THETAプラグイン側は、「シリアルで受けたコマンドを内部に伝え」「内部からのコマンド応答をシリアルで返す」というお仕事をしてもらいます。

M5Stack側は、「M5Stackユーザーミーティング vol.1」向けに、私が途中まで作っていた「LV表示に失敗したwifiリモコン 」の通信の出入り口を、HTTP通信からシリアル通信へ置き換えるようにソースコード流用をすると簡単です。必要最小限なものだけ流用します。

今回作ったソースコードは、M5Stack以外の無線通信を持たないarduino系マイコンにも応用できます。THETA Vを色々なarduinoから操れちゃいますね。

[LV表示に失敗したwifiリモコンの余談] THETAが送るライブビューのデータ量 > M5Stackで受けきれるデータ量 の度がすぎていたため、M5Stack側で間引き処理する前に、受信データが欠損していました。 THETA側を「クライアントモード」、M5Stack側を「アクセスポイント」として接続すると、webAPIでの通信にTHETAプラグインの処理を介在させられるので、THETA側でライブビューデータのフレーム間引きやフレームリサイズ(縮小)が行えそうです。もしかしたら・・・リベンジできるかも。 これもTHETAプラグインの将来ネタですね。

QRコードの表示

上記の仕掛けにより、M5Stackからは、シリアル通信経由でTHETAのwebAPIが使えるかのような状態となります。
THETAのwebAPIの中でも「state」というコマンドの応答の中に「_latestFileUrl]という項目があります。
この文字列をQRコード化すればよさそうです。
ただし、THETAプラグインからのwebAPI内部呼び出しの応答となるため、URLに含まれるIPアドレスが「172.0.0.1:8080」となっています。これをダイレクトアクセスしたときのIPアドレス「192.168.1.1」に置換する必要があります。

ここまでできると、M5Stackで文字列をQRコード化する方法は簡単です。

M5.Lcd.qrcode("String型のURL文字列");

とすると画面いっぱいにQRコードが表示されます。

ソースコードの説明

今回作成したプラグインのファイル一式をこちらにおいておきます。

THETAプラグイン側

ベースとした「GPS/GNSS receiver plug-in Sample for RICOH THETA」にあるファイルの中でも、HttpConnector.java、MainActivity.javaの2つのファイルを触りました。
元のコードの説明はこちらをご参照ください。
以降で、今回追加修正したコードをブロックに分けて説明します。

HttpConnector.java

引数から POST/GET、URL、JSON文字列を与え、JSON文字列の応答を受け取るcrulコマンドのような処理を追加しました。その他は触っていません。

1点だけ注意点があります。

HttpConnector.java
            // Receive response
            is = postConnection.getInputStream();
            responseData = InputStreamToString(is); //camera.listFilesのサムネイル付き応答のような長い文字列は、1回では受けきれない

あまり多くの種類のコマンドを試せていないのですが、camera.listFilesでサムネイルを取得を試していたときに、InputStreamToString()の戻り値として受け取ったデータが途切れていました。後半がありません。便利そうなメソッドを利用しているのですが、長いデータの終端を正しく認識できていないようです。
1Byteづつ読むようなメソッドで置き換えて試してみる必要がありますが、今回は見送ります。

汎用webAPI実行処理
HttpConnector.java
    /**
     * Generic web API execution function
     *
     * @param postget "POST" or "GET"
     * @param path webAPI path
     * @param jsonStr JSON String
     * @return Respons string / Error message
     */
    public String httpExec(String postget, String path, String jsonStr) {
        HttpURLConnection postConnection = createHttpConnection(postget, path);

        String responseData = "";
        String errorMessage = null;
        InputStream is = null;

        try {
            // send HTTP POST/GET
            if (jsonStr.length() != 0) {
                JSONObject input = new JSONObject(jsonStr);
                OutputStream os = postConnection.getOutputStream();
                os.write(input.toString().getBytes());
                postConnection.connect();
                os.flush();
                os.close();
            } else {
                postConnection.connect();
            }

            // Receive response
            is = postConnection.getInputStream();
            responseData = InputStreamToString(is); //camera.listFilesのサムネイル付き応答のような長い文字列は、1回では受けきれない
            // JSON文字列をパースせずにそのまま返す。
            errorMessage = responseData;    //Successful

        } catch (IOException e) {
            e.printStackTrace();
            errorMessage = e.toString();
            InputStream es = postConnection.getErrorStream();
            try {
                if (es != null) {
                    String errorData = InputStreamToString(es);
                    JSONObject output = new JSONObject(errorData);
                    JSONObject errors = output.getJSONObject("error");
                    errorMessage = errors.getString("message");
                }
            } catch (IOException e1) {
                e1.printStackTrace();
            } catch (JSONException e1) {
                e1.printStackTrace();
            } finally {
                if (es != null) {
                    try {
                        es.close();
                    } catch (IOException e1) {
                        e1.printStackTrace();
                    }
                }
            }
        } catch (JSONException e) {
            e.printStackTrace();
            errorMessage = e.toString();
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        return errorMessage;
    }

MainActivity.java

大きくは、元のソースコードでGPS/GNSSレシーバーからのデータ受信を監視しているループの中身だけ入れ替えました。この部分だけ記事として掲載します。
他は無線ランプの操作をしないよう修正していますが、これは説明を割愛します。

シリアル通信の受信&応答ループ
MainActivity.java
                    //Serial Pooling
                    while(mFinished==false){

                        //シリアル通信 受信ポーリング部
                        byte buff[] = new byte[256];
                        int num = port.read(buff, buff.length);
                        if ( num > 0 ) {
                            String rcvStr = new String(buff, 0, num);
                            String[] splitSentence = rcvStr.split(",", 0);

                            Log.d("REMOTE", rcvStr);
                            out.write(buff,0, rcvStr.length());

                            String Result = "";
                            HttpConnector camera = new HttpConnector("127.0.0.1:8080");
                            if (splitSentence.length == 3) {
                                Result = camera.httpExec(splitSentence[0], splitSentence[1], splitSentence[2]);
                                Log.d("REMOTE", "camera.httpExec():" + Result);
                            } else if ( splitSentence.length == 2 ) {
                                //Log.d("REMOTE", "param1:" + splitSentence[0] + ", param2:" + splitSentence[1]);
                                Result = camera.httpExec(splitSentence[0], splitSentence[1], "");
                                Log.d("REMOTE", "camera.httpExec():" + Result);
                            } else if ( splitSentence.length >= 4 ) {
                                String param = "";
                                for (int i=2; i<splitSentence.length ; i++){
                                    param += splitSentence[i];
                                    if (i != (splitSentence.length-1)) {
                                        param += ",";
                                    }
                                }
                                Result = camera.httpExec(splitSentence[0], splitSentence[1], param);
                                Log.d("REMOTE", "camera.httpExec():" + Result);
                            } else {
                                //無処理にしたい
                                Result = "";
                            }
                            //send response
                            Result += "\n";
                            port.write(Result.getBytes(), Result.length());

                        }
                        //ポーリングが高頻度になりすぎないよう10msスリープする
                        Thread.sleep(10);

                        //インターバル撮影 開始/停止指示 の実行部
              変更していない
                    }

ちょっとした事なのですが、
M5Stack側でデータの終端を分かりやすくするため、以下のように"\n"をつけています。("\r"のほうが良かったカモ・・・)
HttpConnector.javaの説明で「1点だけ注意点」とした問題は、使用している便利メソッドにこの手の終端認識をする配慮がないせいカモしれません。まだ原因がわかっていないので追々調査が必要です。

MainActivity.java
                            //send response
                            Result += "\n";
                            port.write(Result.getBytes(), Result.length());

M5Stack側

M5Stackの開発環境構築についての記載は、最新の方法が刻々変化しますので丸ごと割愛します。
私はarduino IDEをつかってビルドや書き込みやデバッグもしています。
「M5Stack arduino IDE 開発環境構築」あたりで検索すると沢山の情報が出てきます。

ソースコードの置き場所はAndroidStudio用プロジェクトファイル一式の中に以下のように含ませています。

02_M5Stack用ソースコード置き場.png

1ファイルにまとめてありますが、ぼちぼち長いコードです。それなりに関数の数があります。
このコードを流用する際、静止画の撮影指示を一方的にするだけ(応答を無視する)なら、"ThetaAPI_Post_コマンド名"の関数群は不要です。

  String strJson = ExecWebAPI("POST", "/osc/commands/execute", "{\"name\": \"camera.takePicture\" }", HTTP_TIMEOUT_NORMAL);

のように書けばよいです。
今後まじめにフルリモコンへ育てることも考慮してもろもろ書いています。

M5StackはデュアルコアのCPU(ESP32)を使用しており、土台となるファームウェアにはFreeRTOSが組み込まれています。
この仕組みを利用し、コマンド送信と応答受信を別タスクに分け、異なるコアに処理を割り当て、応答受信部は処理優先度を上げてあります。こうすることで、THETA側から多量のデータを受信するケースでも、極力受信取りこぼしを避けられるよう配慮しました。
しかし、この部分は、同じ仕組みを持たない他のarduinoでは1つの処理に統合したほうがよいです。大切な部分であるため、抜き出して記載しておきます。

メインタスク側(setupやloopと同じコアに割り当て)

汎用コマンド実行部
M5QR_sample.ino
String ExecWebAPI(String strPostGet, String strUrl, String strData, unsigned int uiTimeoutSec)
{
  unsigned long ulStartMs;
  unsigned long ulCurMs;
  unsigned long ulElapsedMs;
  unsigned long ulTimeoutMs;
  int           iResponse=0;

  ulTimeoutMs = (unsigned long)uiTimeoutSec * 1000;
  
  //Edit & Send CMD
  String SendCmd;
  if ( strData.length() > 0 ) {
    SendCmd = strPostGet + "," + strUrl + "," + strData;
  } else {
    SendCmd = strPostGet + "," + strUrl ;
  }
  bSerialBufEnter = false;
  strSerialRcv = "";
  Serial.print(SendCmd);

  //Receive response
  String line = "";
  ulStartMs = millis();
  while(1){
    if (bSerialBufEnter == true) {
      line = strSerialRcv ;
      bSerialBufEnter = false;
      break;
    } else {
      delay(10); //sleep 10ms
    }
    
    ulCurMs = millis();
    if ( ulCurMs > ulStartMs ) {
      ulElapsedMs = ulCurMs - ulStartMs;
    } else {
      ulElapsedMs = (4294967295 - ulStartMs) + ulCurMs + 1 ;
    }
    if ( (ulTimeoutMs!=0) && (ulElapsedMs>ulTimeoutMs) ) {
      break;
    }
  }
  
  return line ;
}

シリアル受信タスク側(上記と異なるコアに割り当て)

シリアル受信タスク
M5QR_sample.ino
void    ReceiveSerial(void * pvParameters)
{
  bool bEnd = false ;
  
  for(;;) {
    if ( Serial.available() > 0 ) {
      int iPos=0;
      while (Serial.available()) {
        char c = Serial.read();
        if (  c == '\n' ) {
          bEnd = true;
          break;
        } else {
          sSerialBuf[iPos] = c;
          iPos++;
        }
      }
      sSerialBuf[iPos] = 0x00;
      strSerialRcv += String(sSerialBuf);
      if ( bEnd == true ) {
        bSerialBufEnter = true;
        bEnd = false;
      }
      
    } else {
      delay(10); //sleep 10ms
    }
  }
}

動作説明

動作させている様子を動画にしました。以下画像をクリックするとyoutubeへ飛びます。
(余談ですが、パソコンブラウザで最大解像度表示すると、動画タイトルのQRコードを読めますよ!)

動作させている様子を動画にしました

以降に細かな振る舞いについて説明します。

  • THETAの状態表示
    動画だと分かりにくいのですが画面上部にTHETAの状態を常時表示しています。
    「BUSY」は撮影 可/不可、「BATT」はバッテリ残量、「CapStat」は本体側連続撮影状態です。
    以下画像は上から「THETA未接続」「通常時」「撮影終了待ち時」「本体側連続撮影中」のときに撮影したものです。
    状態説明_まとめ.jpg

  • M5Stackからの撮影とQRコード表示
    M5Stackの中央ボタンを押すと撮影し、撮影が終わると自動でQRコードが表示されます。
    THETA本体とダイレクト接続したスマートフォンでQRコードを読み取るとブラウザなどですぐに画像が表示されます。保存も可能です。

  • 画面のクリアと再表示
    左ボタンを押すとQRコードを消せます。右ボタンを押すとQRコードを表示できます。

  • 本体操作(1枚撮影)とQRコード表示
    本体操作で撮影しても、右ボタンをおすと最新画像へのURLがQRコード化されます。

  • 本体操作(インターバル撮影)とQRコード表示
    本体操作のインターバル撮影でも上記と同様です。インターバル撮影中は右ボタンをおす度にQRコードの模様が変わることが動画からも判ると思います。

  • 電池がなくても動くんです!
    最後はおまけです。THETA Vからの電源供給だけでM5Stackが動けることを示しています。
    USBシリアル通信ができるarduinoボードをTHETA Vに刺すと、Lチカとかが余裕でできることがわかります。

まとめ

THETA VにM5Stackを繋ぐと、裏蓋(電池)なくても動くよ!

は、もういいですね。すみません。

技術的には、THETAプラグインを使ってシリアル通信でもwevAPIのコマンド体系を使いTHETA Vを操れるようにできました。
操る側のM5Stack側は最小限な実装ですが、もっと高機能な有線リモコンにもできそうです。
このコードは、無線機能を持たないarduino系マイコン全般にも応用が利く事例です。

「QRコードで画像配布をする」という目的は、もっとも簡単な事例がひとつ果たせました。
あと2つ、より便利にできる可能性も示してあります。今後はこれらにもトライしてみたいです。
もちろん、この記事を参照した方々が先に達成することは大歓迎です。

RICOH THETAプラグインパートナープログラムについて

THETAプラグインをご存じない方はこちらをご覧ください。
パートナープログラムへの登録方法はこちらにもまとめてあります。
興味を持たれた方はTwitterのフォローとTHETAプラグイン開発コミュニティ(Slack)への参加もよろしくおねがいします。

12
5
3

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
12
5