今話題のビデオ通話アプリzoomを用いて、「安くてお手軽に作れるテレイグジスタンスロボット」の構築を目指す、今回は本編で触れなかったサンプルコードについて簡単な解説を行います。仕組みを理解してカスタマイズする際にご参照ください。

 本編は3回分の連載になっています。こちらからご覧ください。

クラウドサーバのコード

 連載第2回で準備したクラウドサーバは、Firebase Cloud Functionsで実行するためにjavascript(nodejs)でコーディングされています。また、サンプルコード内に、画面表示のための各種webコンテンツも含まれます。

ファイル構成

 第2回で説明したように、コード本体はfunctionsディレクトリ内に全て収録されています。このうち、Cloud Functionsが直接実行するのはindex.jsです。

 index.jsの冒頭付近は、Basic認証の実装及びHTTPアクセスに対するwebコンテンツへのリダイレクトを設定しています。第2回で説明したように、本来のFirebase Hostingと異なる方法でwebコンテンツを構築しているので、このような処理を記述しています。

参考:https://qiita.com/567000/items/65f55eda8d7c6df09138

const app = express()

app.all('/*', basicAuth(function(user, password) {
  const id = `${functions.config().basic.id}`
  const pwd = `${functions.config().basic.pwd}`

  return user === id && password === pwd;
}));

app.get('/css/*', function (request, response) {
  redirectUrl(request, response);
})
app.get('/js/*', function (request, response) {
  redirectUrl(request, response);
})
app.get('/images/*', function (request, response) {
  redirectUrl(request, response);
})

function redirectUrl(request, response){
    try{
      response.status(200).send(fs.readFileSync(`./static`+request.url).toString());
    }
    catch(e){
      response.status(400).send(e.toString());
    }
  
}

 クライアントからのアクセスに対応して実行されるメインのメソッドは、下記のbasemenuになります。
 メソッド冒頭付近は、HTTP GETのパラメータやアクセスURLなどを取得・整理しています。その後switch~caseで、アクセス先のURLに応じた処理に分岐します。
 アクセス先の各URLにおけるサービスは下記の通りです。

  • /,/index.html…最初のミーティングID/パスワード入力画面を提供
  • /connect…POSTされたID・パスワードでzoom通話を開始し、操作パネルと通話画面を提供
  • /controll…操作パネルからPOSTされたコマンドをfirestoreに書き込む
  • /leave…通話終了後の画面を提供
async function basemenu(request,response){
    var respCode=400;
    var respBody="OK."

    var params = request.url.split(/[?&]/)
    var httpget={}
    var url = params[0]
  
  
    if(params.length>1){
      for(var i=1;i<params.length;i++){
        var s=params[i].split('=')
        if(s.length==2) httpget[s[0]]= decodeURIComponent(s[1])
      }
    }
  
    switch(url){
      case '/':
      case '/index.html':
        respCode=200;
        respBody = fs.readFileSync(`./static/select.ejs`).toString();
        break;

      case '/leave':
        respCode=200;
        respBody="通話が完了しました。<br /><a href='./index.html' target='_top'>[戻る]</a>"
        break;

      case '/controll':
        respCode=200;
        var json = JSON.parse(request.body.json);
        console.log("recv cmd:");
        console.log(json);
        respBody=""

        param={
          cmd:json.cmd,
          isdid:false,
          recvtime:admin.firestore.Timestamp.fromDate(new Date( Date.now())),
          user:json.user
        };

        set_command(json.id,param);

        break;


      case '/connect':
        respCode=200;
        var json = JSON.parse(request.body.json);
        var apiKeyData = auth.getAPIKeyData();
        var apiKey = "";
        if(apiKeyData) {
            apiKey = apiKeyData.apikey;
        }
        const display_name = "Guest";
        const id = json.meetingid;
        const password = json.password;
        const ZOOMVIEW_HTML = "./static/zoomview.ejs";
        const GUEST=0;

        var template = fs.readFileSync(ZOOMVIEW_HTML, 'utf8');
        var renderView = ejs.render(template, {
            hostname: admin.instanceId().app.options.projectId+".web.app",
            apikey: apiKey,
            meetingNum: id ,
            password: password,
            display_name: display_name,
            role: GUEST,
            signature: auth.generateSignature(id, GUEST),
        });

        respBody=renderView;


        break;
    }
  
    response.status(respCode).send(respBody);
}  

 /connectでzoomの通話認証を取得するため、zoom SDKで取得したAPIキー・APIシークレットを使ってシグネチャを取得しています。これらの具体的な処理は/controllers/auth.jsで実装されています。

 zoom通話が成立して、画面に操作パネルとビデオ通話を表示するところは、static/zoomview.ejs及びstatic/js/index.jsで行っています。前者は操作パネル及びビデオ通話表示用のインラインフレームを、後者はzoomMtgによるビデオ通話表示を、それぞれ実装しています。

 ビデオ通話が終了したら、インラインフレーム内に/leaveの内容を表示します。このURLへのリダイレクトは、static/zoomview.ejsの<% hostname %>に置換された箇所を元に、static/js/index.jsにてzoomMtgの機能で設定しています。

  • static/zoomview.ejsの該当箇所抜粋
      <script src="https://source.zoom.us/zoom-meeting-1.7.8.min.js"></script>
      <script type="text/javascript">
          var hostname = "<%-hostname %>";
          var apikey = "<%-apikey %>";
          var meetingNum = "<%-meetingNum %>"
          var password = "<%-password %>"
          var display_name = "<%-display_name %>"
          var role = "<%-role %>"
          var signature = "<%-signature %>";
      </script>
      <script src="./js/tool.js"></script>
  • static/js/index.jsの該当箇所抜粋
    $.i18n.reload("jp-JP");
    ZoomMtg.reRender({lang: "jp-JP"});

    var meetConfig = {
        apiKey: apikey,
        meetingNumber: meetingNum,
        userName: display_name,
        passWord: password,
        leaveUrl: `https://${hostname}/leave`,
        role: role,
    };

 本来はwebページ全体を/leaveにリダイレクトすべきですが、インラインフレームを持ちいた関係でこのように若干中途半端な仕様になっています。

 操作パネルのボタンを押すと、/controllにHTTP POSTされます。この時パラメータとしてコマンド名などをbodyに収納しています。また、ロボット側でしばらくDBの更新が無いとポーリングの頻度が下がるため、通話ウィンドウ表示時に/controllに「nop」(何もしない)のコマンドを送信しています。

  • static/zoomview.ejsの該当箇所抜粋
document.getElementById('turn_left').addEventListener("click", function(e){
    e.preventDefault();
    var params = new Object();
    params.cmd = "turn_left";
    params.id = "<%-meetingNum %>";
    params.user = "<%-display_name %>";
    postData(JSON.stringify(params))
})
document.getElementById('turn_right').addEventListener("click", function(e){
    e.preventDefault();
    var params = new Object();
    params.cmd = "turn_right"
    params.id = "<%-meetingNum %>"
    params.user = "<%-display_name %>";
    postData(JSON.stringify(params))
})

document.getElementById('go_fwd').addEventListener("click", function(e){
    e.preventDefault();
    var params = new Object();
    params.cmd = "go_fwd"
    params.id = "<%-meetingNum %>"
    params.user = "<%-display_name %>";
    postData(JSON.stringify(params))
})
document.getElementById('go_back').addEventListener("click", function(e){
    e.preventDefault();
    var params = new Object();
    params.cmd = "go_back"
    params.id = "<%-meetingNum %>"
    params.user = "<%-display_name %>";
    postData(JSON.stringify(params))
})
document.getElementById('open_zoom').addEventListener("click", function(e){
    e.preventDefault();
    var params = new Object();
    params.cmd = "open_zoom"
    params.id = "<%-meetingNum %>"
    params.user = "<%-display_name %>";
    postData(JSON.stringify(params))
})

window.onload = function () {
    var params = new Object();
    params.cmd = "nop"
    params.id = "<%-meetingNum %>"
    params.user = "<%-display_name %>";
    postData(JSON.stringify(params))
};

function postData(value){
    $.ajax({
        type: 'POST',
        url: '/controll',
        data: {"json":value},
        dataType: 'text'
    }).done(function(data){
        console.log(data);
    }).fail(function(xhr,err){
        console.log(err);
    });
}

ロボット本体のコード

 ロボット本体のプログラムもnodeで実行されるためjavascriptでコーディングされています。ロボット側のソースコードはserverhost.jsのみです。

zoomミーティングの待ち受け

 ロボットの起動時にブラウザでzoomミーティング待ち受けのURLを開く処理は、ソースコード内のopenBrowserメソッドで実装しています。meetingidのファイルに設定したミーティングIDからURLを生成し、openerライブラリを使って開きます。URLを開くだけだとブラウザのタブが増えていってしまうので、URLを開いたら約5秒後にタブを閉じるコマンドを実行しています。

参考:https://superuser.com/questions/583246/can-i-close-firefox-browser-tab-or-firefox-browser-from-ubuntu-terminal

function openBrowser(){
  console.log("open browser.")
  opener(MEETING_URL);
  setTimeout(function(){
      console.log("exec browser tab close.")
      exec('wmctrl -a firefox; xdotool key Ctrl+w');    
    },5000);
}

コマンドの待ち受け

 ロボットからのコマンドを受信して実行する部分は下記になります。firebase-admin SDKでドキュメントを開いたら、onSpapshotメソッドを使えば、websocketでfirestoreと接続し、DBの更新をリアルタイムで検知して処理を実行できます。

参考:https://qiita.com/sano1202/items/4e378f86c1bb97196115

	cmdRef.onSnapshot(function(doc) {

		var data=doc.data();
		if(data.isdid===false){
			switch(data.cmd){
				case 'turn_left':
					console.log("turn_left");
					rover_twist(0,15/180*3.14);
					break;
				case 'turn_right':
					console.log("turn_right");
					rover_twist(0,-15/180*3.14);
					break;
				case 'go_fwd':
					console.log("go_fwd");
					rover_twist(0.2,0);
					break;
				case 'go_back':
					console.log("go_back");
					rover_twist(-0.2,0);
					break;
				case 'open_zoom':
					console.log("open_zoom");
					openBrowser();
					break;
				case 'nop':
					console.log("nop");
					break;
				default:
					console.log("unknown command '"+data.cmd+"'");
					break;
			}
			cmd_isdid();
		}
	});

 実際にロボットにコマンドを送信するのがrover_twistメソッドです。シェルでrostopic pubコマンドを実行し、/rover_twistで車輪を操作する仕組みで、ROSを利用しています。また、1回の命令で規定時間ワンショットで動かすため、車輪駆動の命令を実行したらCONTROLL_STOP_INTERVALの設定時間(デフォルトで2秒)後に車輪を停止する処理を実行させます。

const CONTROLL_STOP_INTERVAL=2000;

async function rover_twist(linear_x,angluar_z)
{
	//車輪停止処理を実行予定なら止める
	if(timerID){
		clearTimeout(timerID);
		timerID=undefined;
	}

	timerID = setTimeout(function(){
		exec('rostopic pub -1 /rover_twist geometry_msgs/Twist "{linear:{x: 0.0}, angular:{z: 0.0}}"', (err, stdout, stderr) => {
			if (err) {
				console.log(`stderr: ${stderr}`)
				return
			}
			console.log(`stdout: ${stdout}`);
			});	
	}, CONTROLL_STOP_INTERVAL);

	exec(`rostopic pub -1 /rover_twist geometry_msgs/Twist "{linear:{x: ${linear_x}}, angular:{z: ${angluar_z}}}"`, (err, stdout, stderr) => {
    if (err) {
      console.log(`stderr: ${stderr}`)
      return
    }
		console.log(`stdout: ${stdout}`)

		//一定時間後、止める
  });
}

自動実行スクリプト

 起動時に実行されるzoom_start.shの概要について説明します。

 SCRIPT_DIRはファイルが存在するパスです。最後にserverhost.jsを実行するときにカレントディレクトリを合わせるため使います。

 3か所呼ばれているsetup.bashはROSを動かすための設定です。これらを実行しないとROSコマンドへのパスが不十分でロボットが制御できません。

 その後に、メガローバーのROSドキュメントにもあるようにロボットとの通信準備を行います。chmodでロボットとの接続デバイスを読み書きできるようにし、rosseralの起動はrosserial.lunchに記述して呼び出しています。

 すべての準備が整ったら、serverhost.jsを実行しています。

#!/bin/bash
SCRIPT_DIR=$(cd $(dirname $0); pwd)

source /opt/ros/melodic/setup.bash
source /home/rover/catkin_ws/devel/setup.bash
source /home/rover/catkin_ws_isolated/install_isolated/setup.bash

echo pwd | sudo -S chmod 666 /dev/ttyUSB0
roslaunch megarover_samples rosserial.launch &

cd $SCRIPT_DIR
node ./serverhost.js

 以上のように、準備にかかる手順は長かったですが、実際のコードは非常にシンプルかつコンパクトにまとまっており、機能を追加したり台車の機種等に応じて処理を変更するといったことも容易にできると思います。