Help us understand the problem. What is going on with this article?

Webブラウザからドローンにコマンドを送る

はじめに

このページは,

ドローン操作システムを作ろう

の1ページです.
全体を見たい場合は上記ページへお戻りください.

概要

前回は,PythonプログラムからMQTTでドローンを操作するコマンドを送ってみました.
しかし,WebブラウザとPythonのGUIプログラムを並べて使うというのは,イマイチ格好わるいです.
やはりWebブラウザからフルオペレーションできるほうが Cool! ですね.
今回はそれをやります.

システム構成

browser_operation.png

ドローンのシミュレータは前回使った 「gui_sitl_pubsub.py」 です.

Webブラウザ用のプログラムを 「map_button.html」 として新しく作ります.

Webブラウザ用のプログラム

ここ を右クリック-[名前を付けて保存]するか,
以下のコードをコピー&ペーストしてファイルを作成してください.

map_button.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" /><!-- 文字コードはutf-8を使用する -->
    <title>Button Control</title><!-- タイトルバーに表示されるメッセージ -->

    <!-- 以下の2行でLeafletで使用するスタイルシート(.css)とライブラリ(.js)を読み込む -->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css" integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==" crossorigin=""/>
    <script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js" integrity="sha512-GffPMF3RvMeYyc1LWMHtK8EbPv0iNZ8/oTtHPx9/cc2ILxQ+u905qIwdpULaqDkyBKgOaB57QTMg7ztg8Jm2Og==" crossorigin=""></script>

    <!-- マーカーの移動・回転ができるプラグインを読み込む -->
    <script src="MovingMarker.js" type="text/javascript"></script>
    <script src="leaflet.rotatedMarker.js" type="text/javascript"></script>


    <!-- 以下の2行で,MQTT over Websocketを使うライブラリ(.js)を読み込む -->
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script>
    <script src="mqttws31.js" type="text/javascript"></script>

    <!-- ログ表示用のスタイルシートなので,これは消さない -->
    <style>
    .box {
        width: 440px;
        float: left;
        margin: 0 20px 0 20px;
    }

    .box div, .box input {
        border: 1px solid;
        -moz-border-radius: 4px;
        border-radius: 4px;
        width: 100%;
        padding: 5px;
        margin: 3px 0 10px 0;
    }

    .box div {
        border-color: grey;
        height: 300px;
        overflow: auto;
    }

    div code {
        display: block;
    }

    #second div {
        font-size: 0.6em;
    }
    </style>
</head>

<body>
<!-- このmapidと名付けられたdiv要素の中に地図を表示する -->
<div id="mapid" style="width: 950px; height: 400px;"></div>

<!-- エディットボックスを作る部分.#firstの方は削除した -->
<div id="second" class="box">
    <h2>Logs</h2>
    <div></div>
</div>

<!-- ドローンを操作するボタンを配置 -->
<div>
    <h2>Control</h2>
    <div>
        <form><!-- onclickイベントでOnButtonClick関数を呼んで,ドローンにコマンドをsendする -->
        <input type="button" value="GUIDED" onclick="OnButtonClick('GUIDED');" ><input type="button" value="RTL" onclick="OnButtonClick('RTL');" ><br>
        <input type="button" value="ARM" onclick="OnButtonClick('ARM');" ><input type="button" value="DISARM" onclick="OnButtonClick('DISARM');" ><br>
        <input type="button" value="TAKEOFF" onclick="OnButtonClick('TAKEOFF');" ><input type="button" value="LAND" onclick="OnButtonClick('LAND');" ><br>

        <input type="button" value="GOTO" onclick="OnButtonClick('GOTO');">
        <input class='form-control' type="text" size="10" name="lat" id="lat" placeholder="Latitude" value="35.893246" />
        <input class='form-control' type="text" size="10" name="lon" id="lon" placeholder="Longitude" value="139.954909" />
        <input class='form-control' type="text" size="3" name="alt" id="alt" placeholder="Altitude" value="30" />
        </form>
    </div>
</div>

<!-- <script></script>で囲まれた部分がJavaScript -->
<script>
    // mapidと名の付いたdiv要素に地図を作成し,視点は柏の葉キャンパス駅前付近,ズームレベルは16に設定
    var mymap = L.map('mapid').setView([35.894087,139.952447], 17);

    // OpenStreetMapのタイルレイヤーを作る
    var tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
        attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>',
        maxZoom: 19
    });
    tileLayer.addTo(mymap); // 作成したtileLayerをmymapに追加する

    // マーカーにする画像を読み込む
    var quad_x_Icon     = L.icon({ iconUrl: 'quad_x-90.png', iconRetinaUrl: 'quad_x-90.png', iconSize: [50, 50], iconAnchor: [25, 25], popupAnchor: [0, -50] });

    // Log表示用の関数部分
    var has_had_focus = false;
    var pipe = function(el_name, send) {
        var div  = $(el_name + ' div');
        var inp  = $(el_name + ' input');
        var form = $(el_name + ' form');

        var print = function(m, p) {
            p = (p === undefined) ? '' : JSON.stringify(p);
            div.append($("<code>").text(m + ' ' + p));
            div.scrollTop(div.scrollTop() + 10000);
        };
        return print;
    };
    var debug = pipe('#second');

    // MQTT over WebSocketの初期化
    var wsbroker = location.hostname;   // MQTTブローカーは自分自身
    var wsport = 15675; // MQTTの標準ポート番号は1883だが,WebSocketは15675とした(RabbitMQと同じ仕様)

    // MQTTのクライアントを作成する クライアントID名はランダムに作る
    var client = new Paho.MQTT.Client(wsbroker, wsport, "/ws", "myclientid_" + parseInt(Math.random() * 100, 10));

    //== ここからボタンを押した時の処理=============================================
    // ドローン操作用のコマンド
    var command = {
                "command":"None",
                "d_lat":"0",
                "d_lon":"0",
                "d_alt":"0",
    }

    // HTML上のボタンが押された時の処理
    function OnButtonClick(str) {
        command["command"] = str;   // 引数の文字列がそのままコマンドになる

        // GOTOのときは,緯度/経度/高度も取得してコマンドを作る
        if( str == "GOTO" ) {
            lat = document.getElementById("lat").value;
            lon = document.getElementById("lon").value;
            alt = document.getElementById("alt").value;
            command["d_lat"] = lat;
            command["d_lon"] = lon;
            command["d_alt"] = alt;
        }

        // JSON型にしてからMQTTでPublishする
        json_command = JSON.stringify(command);     // JSON型にする
        message = new Paho.MQTT.Message(json_command);      // MQTTのメッセージパケットを作る
        message.destinationName = "ctrl/001";   // トピック名を設定
        client.send(message);   // MQTTでPubする
        debug("SEND ON " + message.destinationName + " PAYLOAD " + message.payloadString);   //debugボックスに表示
    }
    //=======================ボタンを押した時の処理 ここまで=============

    // 切断時のコールバック
    client.onConnectionLost = function (responseObject) {
        debug("CONNECTION LOST - " + responseObject.errorMessage);
    };

    // ドローン情報保存用の連想配列
    var drones = new Array();   // 緯度経度などのデータを保存する連想配列
    var markers = new Array();  // マーカーハンドラを保存する連想配列

    // MQTTメッセージSubscribe時のコールバック
    client.onMessageArrived = function (message) {
        debug("RECEIVE ON " + message.destinationName + " PAYLOAD " + message.payloadString);   //debugボックスに表示

        var drone_name = message.destinationName;   // ドローン名はトピック名とする

        var drone_data = JSON.parse( message.payloadString );   // ドローンのデータを連想配列にして格納

        var mode = drone_data.status.Arm;           // ARM/DISARM
        var arm  = drone_data.status.FlightMode;    // フライトモード
        var lat  = parseFloat( drone_data["position"]["latitude"] );  // 緯度
        var lon  = parseFloat( drone_data["position"]["longitude"] ); // 経度
        var alt  = parseFloat( drone_data["position"]["altitude"] );  // 高度
        var ang  = parseFloat( drone_data["position"]["heading"] );   // 方位

        // ポップアップ用に文字列を作る +=で追加していく
        var drone_popmessage = drone_name + '<br>';
        drone_popmessage += mode + ',' + arm + '<br>';
        drone_popmessage += lon + ',' + lat + '<br>';
        drone_popmessage += alt + '[m], ' + ang + '[deg]<br>';

        // drone_nameで登録されている連想配列があるか? ない->新規にマーカー作成 ある->なにもしない
        if( !drones[drone_name] ) {

            // movingMarkerでマーカーを作成し,rotatedMarkerのrotationオプションをつける
            markerHandle = L.Marker.movingMarker([[lat, lon]], [], 
                            {   rotationAngle: 0,   // 回転角度
                                rotationOrigin: 'center center',    // 回転中心
                                title:drone_name,
                                contextmenu: true,
                                contextmenuItems: [{
                                    text: drone_name,
                                    index: 0
                                }, {
                                    separator: true,
                                    index: 1
                                }]
                            });
            markerHandle.bindPopup( drone_popmessage ); // ポップアップメッセージの設定
            markerHandle.options.icon = quad_x_Icon;    // マーカーアイコンをオリジナル画像に
            markerHandle.options.autostart = true;      // MovingMarker機能を自動スタート
            markerHandle.addTo( mymap );                // 地図へ追加

            markers[drone_name] = markerHandle; // マーカー用連想配列に今回作ったマーカー情報を保存
        } else {
            markerHandle = markers[drone_name]  // 保存されているマーカー情報をdrone_name(トピック名)をキーにして呼び出す
            markerHandle.moveTo([lat,lon], 1000);  // MovingMarkerの移動機能
            markerHandle.setRotationAngle(ang);    // ratatedMarkerの回転機能
            markerHandle.setPopupContent( drone_popmessage );   // メッセージ更新
        }
        drones[drone_name] = drone_data; // ドローンデータ用連想配列の情報を最新のメッセージに更新

    };

    // MQTTの接続オプション
    var options = {
        timeout: 3,
        onSuccess: function () {
            debug("CONNECTION SUCCESS");
            client.subscribe('drone/#', {qos: 1});
        },
        onFailure: function (message) {
            debug("CONNECTION FAILURE - " + message.errorMessage);
        }
    };

    // サーバーがHTTPS対応だった時の処理
    if (location.protocol == "https:") {
        options.useSSL = true;
    }

    // 最初にメッセージを表示してMQTTをブローカーに接続
    debug("CONNECT TO " + wsbroker + ":" + wsport);
    client.connect(options);        // 接続
</script>

</body>
</html>

保存したmap_button.htmlを,Webサーバーが利用するフォルダへコピーします.
管理者権限が必要なのでsudoを付けます.

apacheのフォルダへコピー
$ sudo cp map_button.html /var/www/html/

プログラム解説

前々回の marker_moving_rotated.html から大きく変化した部分だけ解説します.

body部

前回のGUIプログラムの様に,ボタンをクリックするとコマンドを送信するようにします.

inputフォームを配置
<!-- ドローンを操作するボタンを配置 -->
<div>
    <h2>Control</h2>
    <div>
        <form><!-- onclickイベントでOnButtonClick関数を呼んで,ドローンにコマンドをsendする -->
        <input type="button" value="GUIDED" onclick="OnButtonClick('GUIDED');" ><input type="button" value="RTL" onclick="OnButtonClick('RTL');" ><br>
        <input type="button" value="ARM" onclick="OnButtonClick('ARM');" ><input type="button" value="DISARM" onclick="OnButtonClick('DISARM');" ><br>
        <input type="button" value="TAKEOFF" onclick="OnButtonClick('TAKEOFF');" ><input type="button" value="LAND" onclick="OnButtonClick('LAND');" ><br>

        <input type="button" value="GOTO" onclick="OnButtonClick('GOTO');">
        <input class='form-control' type="text" size="10" name="lat" id="lat" placeholder="Latitude" value="35.893246" />
        <input class='form-control' type="text" size="10" name="lon" id="lon" placeholder="Longitude" value="139.954909" />
        <input class='form-control' type="text" size="3" name="alt" id="alt" placeholder="Altitude" value="30" />
        </form>
    </div>
</div>

まずは新しく<div>〜</div>要素を作ります.
ボタンやエディットボックスを配置したいので,その中に<form>〜</form>を作ります.
formの中に,inputタグを置いていきます.

<input type="button"〜であればボタン型のフォームが,
<input type="text"〜であればエディットボックス型のフォームが生成されます.

GUIDED,RTL,ARM,DISARM,TAKEOFF,LAND,GOTOのコマンドのボタンを配置します.
(適宜改行<br>を入れる)
緯度/経度/高度を入力できるエディットボックスを配置します.

ボタン型のフォームには,onclick="OnButtonClick(コマンド名)"を追記してあります.
ボタンが押された時に発生するイベントが「onclickイベント」です.
onclickイベント発生時に呼び出す関数がOnButtonClick関数です.
これはscript部分に新たに自作した関数です.

つまり,PythonのGUIプログラムの時と同様に,「ボタンが押されたら,MQTTでPubする関数を呼び出す」というわけです.

なお,GOTOコマンドの時だけは,エディットボックスから緯度/経度/高度を抽出する作業も必要になりますね.

script部

MQTTでPubするJSONデータを作るために,commandという連想配列を作っています.
これにコマンドを書き込んでPubするだけです.

ボタンが押された時の処理
    // ドローン操作用のコマンドの連想配列
    var command = {
                "command":"None",
                "d_lat":"0",
                "d_lon":"0",
                "d_alt":"0",
    }

    // HTML上のボタンが押された時の処理
    function OnButtonClick(str) {
        command["command"] = str;   // 引数の文字列がそのままコマンドになる

        // GOTOのときは,緯度/経度/高度も取得してコマンドを作る
        if( str == "GOTO" ) {
            lat = document.getElementById("lat").value;
            lon = document.getElementById("lon").value;
            alt = document.getElementById("alt").value;
            command["d_lat"] = lat;
            command["d_lon"] = lon;
            command["d_alt"] = alt;
        }

        // JSON型にしてからMQTTでPublishする
        json_command = JSON.stringify(command);     // JSON型にする
        message = new Paho.MQTT.Message(json_command);      // MQTTのメッセージパケットを作る
        message.destinationName = "ctrl/001";   // トピック名を設定
        client.send(message);   // MQTTでPubする
        debug("SEND ON " + message.destinationName + " PAYLOAD " + message.payloadString);   //debugボックスに表示
    }

OnButtonCliek関数では,引数として受け取った文字列("GUIDED"とか"ARM"とか)を,
commandに書き込んでいます.

GOTOコマンドの時だけは,ID名'lat'や'lon','alt'と名のついた要素(=エディットボックスのid)から値(value)を取り出しています.
エディットボックスのフォームは,以下の様に書かれています.

id名を与えれば,JavaScriptからアクセスできる
<input class='form-control' type="text" size="10" name="lat" id="lat" placeholder="Latitude" value="35.893246" />
<input class='form-control' type="text" size="10" name="lon" id="lon" placeholder="Longitude" value="139.954909" />
<input class='form-control' type="text" size="3" name="alt" id="alt" placeholder="Altitude" value="30" />

idとしてlatlonaltを持っています.
Webページ(document)上から特定のidを持つオブジェクトを入手する関数がgetElementByIdです.

例えばdocument.getElementById('lat').valueで緯度のエディットボックスに書き込まれている文字列(value)が得られます.

command連想配列への書き込みが完了したら,
それを素にJSON型のメッセージを作成します.json_command = JSON.stringify(command);
MQTTパケットを新しく作りますが,message = new Paho.MQTT.Message(json_command);
メッセージには宛先となるトピック名を追加するのを忘れてはいけません.message.destinationName = "ctrl/001";
パケットを作り終わったら送信します.client.send(message); // MQTTでPubする

最後におまけでLogs画面にデバッグ表示を書いています.

実行結果

Webブラウザのアドレスバーに
http://localhost/map_button.html
と入力し,Enterしてページを表示させます.

こんな画面が表示されれば成功です.
ドローン操作用のボタンが並んでいることがわかります.

Screenshot at 2019-05-30 18-39-48.png

次は,ドローン側のシミュレータを起動しましょう.

ドローン側シミュレータを起動
$ python gui_sitl_pubsub.py 

起動が完了するとドローンから情報がPubされ,ブラウザ上にマーカーが現れます.
map_sitl.png

ドローンの操作

それでは,ドローンのマーカーをクリックしてポップアップを出したら,
ボタンを押してドローンを動かしてみましょう.

いつもの手順です.
(1)フライトモードをGUIDEDに変更.
(2)ARMする.
(3)TAKEOFF.
(4)GOTOで移動させる.
(5)LANDさせたり,RTLさせたり.

こんな風に動きます.
Screenshot at 2019-05-30 18-55-00.png

おわりに

Webブラウザでドローンの操縦もできるようになりました.

しかし,GOTOで移動ができるのは良いのですが,緯度と経度を数値で入力するのは大変です.
マウスでクリックした位置の座標を取れると便利ですね.
Leafletではそれが可能です.

次回は,マウスでクリックした位置へドローンを移動させてみます.

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away