電動雲台をWebブラウザからウニョウニョ操作するには

サーボモーターを2つ使った、2軸の電動雲台を。

タブレットPCのWebブラウザから、上下左右(パン、チルト)ウニョウニョ操作してみました。四角いエリアでマウス操作すると、雲台を自由に動かすことができます。

サーボモーターの制御は、Raspberry Pi Zero WにPWM制御基板を取り付けて行いました。

このようなシステムの構築方法を記録しておきたいと思います。


Raspberry Piの2軸電動雲台制御システム構築手順

使用する機材

電動雲台

サーボモーターSG90を2つ取り付け可能な雲台です。お値段がお手頃ですが、注文してから到着まで2週間くらいかかりました。

サーボモーター

SG90を2つ購入し、取り付けてみました。同じサイズのサーボモーターは何種類かあり、よりトルクが高いモデルもあるようです。

Raspberry Pi本体

どのRaspberry Piでも大丈夫かと思います。OSはRaspbianを使用させて頂きました。

ピンヘッダが無い、通常のRaspberry Pi Zero Wを購入し、基板の裏面にピンソケットを取り付けてみました。ハンマーヘッダーは、叩いて取り付けられるため、はんだ付けは不要です。

Pi Zeroのオフィシャルケースにはカメラ取付用の変換ケーブルが付属しています。ピンヘッダを裏面に取り付けることで、GPIOと同時にオフィシャルケースのカメラ用のフタを使用することができます。

Raspberry Piを電動雲台に搭載することで、雲台の制御に加えて、カメラとしての役割、写真と動画の撮影に使用することができます。

PWM制御基板

I2CでRaspberry Piと接続可能なPCA9685コントローラーを使用しました。16チャンネルありますが、電動雲台用にチャンネルを2つ使用します。残りの14チャンネルは同じようにサーボモーターを取り付け可能のほか、LEDまたは赤外線LEDを取り付けることで、照明(または暗視カメラ用照明)として使用できそうです。

LED制御の記事はこちらになります。

ジャンパーワイヤー

モーター駆動用の電力系の配線と、I2Cの信号系の配線ともにジャンパーワイヤーを使用しました。メスーメスとオスーメスケーブルを端子に合わせて使用する感じです。

分岐用USB電源端子

モーターを駆動する場合、大きな電流が必要になります。Raspberry Pi経由で大きな電流が流れないように、USB電源を分岐しました。秋月電子通商さんのこちらの製品を使用させて頂きました。

そこそこの容量(5V 3A程度)のUSB電源をご用意頂ければ、少数のモーターを接続しても、電力不足の問題は発生しないかと思います。


配線図

次のような3系統の配線になります。

  1. USB電源ケーブルを2つに分岐して、Raspberry PiとPWM制御基板の電源端子に接続。
  2. I2C端子でRaspberry PiとPWM制御基板を接続。
  3. サーボモーターを接続。

写真のPi Zeroは、基板の裏面にGPIOピンヘッダを取り付けたため、2列の端子の左右が逆になっています。通常の表面のGPIOヘッダの配線は以下になります。

電源配線

microB端子のUSB電源を2つに分けて、Raspberry PiのGPIOと、PWM制御基板の電源端子に接続しました。


I2C配線

4本のケーブルで、PWM制御基板のI2C端子とGPIOの1,3,5,9番端子を接続しました。


サーボモーター配線

SG90の場合、配線の色は以下になります。

  1. 茶:GND
  2. 赤:5V
  3. 橙:PWM(SIG)

PWM制御基板に接続します。

  1. Ch.0にチルト用モーター(上下)
  2. Ch.1にパン用モーター(左右)

OSの設定

I2Cバスドライバの有効化

raspi-configでi2cバスを使用できるように設定後、再起動しました。


電動雲台制御画面の作成

パラメータ調整・コントロール画面の設計

雲台の制御は、Webサーバ(Node.js + Express.js)を使用させて頂こうと思います。

Webブラウザからアクセス可能な2つの画面を作成しました。

  1. サーボモーターのパラメータ調整画面
    • 周波数、turn on/turn offパラメータを入力して、モーターを実際に動かします。
    • パン・チルト(Ch.0と1)それぞれの可動範囲がわかったら、そのパラメータを記録します。
  2. 電動雲台の制御画面
    • 1番で調べたパラメータを読み込み、パン・チルト同時にモータを制御します。

Node.js+Express.jsプロジェクト作成

  1. プロジェクト名は「SG90CamMnt」にしてみました。
    #node.jsインストール
    sudo apt-get install nodejs npm
    #express-generatorインストール
    sudo npm install -g express-generator
    #expressプロジェクト作成
    express --view=ejs SG90CamMnt
    #動作確認
    cd SG90CamMnt
    npm install
    DEBUG=SG90CamMnt:* npm start
    #Webブラウザで確認 http://<Raspberry PiのIPアドレス>:3000
  2. PWM制御に使用するモジュールをインストールしておきます。
    npm install --save i2c-bus pca9685

 ソースファイルの作成・変更

app.jsファイル変更

  1. app.jsファイルを編集します。
    vi app.js

    「var app = express();」行の下に下記を追加しました。

    var app = express();
    // --------------------------------------------------
    // pwm_testページ
    app.get('/pwm_test', function(req, res, next) {
      res.render('pwm_test');
    });
    
    // WebSocketのインスタンス生成
    var WebSocket = require('ws').Server;
    var wss = new WebSocket({port:3001});
    
    // Raspberry Piで何かイベント処理をさせるWsEventHandlerインスタンス生成
    // ws-event-handler.jsファイルにその処理を書きましょう
    var WsEventHandler = require('./ws-event-handler');
    var ev = new WsEventHandler;
    
    // WebSocketの送受信処理
    wss.on('connection', function connection(ws) {
      console.log('WebSocket:接続しました')
      // デバイスの初期化など
      ev.connected(wss);
    
      ws.on('message', function incoming(data){
        console.log("WebSocket:受信しました: " + data);
    
        // 何かイベントに応じたデバイスの処理実行
        ev.handleEvent(wss, JSON.parse(data));
    
        // 受け取ったイベントをWebSocketクライアントにログとして表示
        wss.sendAllClients(data);
      });
      ws.on('close',function close(){
        // デバイスの終了処理など
        ev.closed(wss);
        console.log('WebSocket:切断しました');
      });
    });
    
    // 全クライアントにデータを送信するメソッドを追加しておきます
    wss.sendAllClients = function SendAllClients(data) {
      this.clients.forEach(function each(client){
        // 全クライアントにデータ送信
        client.send(data);
      });
    }
    // --------------------------------------------------

    WebSocketサーバを作成、データを送受信しつつ、ws-event-handler.jsファイルの関数を実行する内容です。


views/index.ejsファイル変更

  1. index.ejsファイルをそっくり入れ替えます。
    vi views/index.ejs

    電動雲台の制御画面になります。

    <!DOCTYPE html>
    <html lang="ja">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>SG90カメラマウント制御</title>
      <link rel='stylesheet' href='/stylesheets/style.css' />
      <style>
        body {
          margin: 0.5em;
          border: 0;
          padding: 0.5em;
        }
        dt { background-color: aliceblue; }
        .ope_panel {
          outline: 1px solid blue;
          margin: 0px;
          padding: 0px;
          border: 0px;
        }
        .tableStyle {
          display: table;
          clear: both;
          width: auto;
          /* outline: solid 1px red;       */
        }
        .tableStyle dl
        {
          /* outline: solid 1px green; */
          margin:0px;
          padding: 0px;
          border: 0px;
        }
        .tableStyle dt,
        .tableStyle dd {
          padding: 0 1em;
          /* outline: solid 1px blue;       */
        }
        .tableStyle dt,
        .tableStyle dd {
          display: table-cell;  
          width: auto;
        }    
      </style>
    </head>
    
    <body>
      <a href="pwm_test">pwm制御パラメータ調整</a>
      <h1>SG90カメラマウント制御</h1>
      <div id="app">
        <section>
          <h2>制御パラメータ</h2>
          <div class="tableStyle">
            <dl>
              <dt>共通</dt>
              <dd>
                <dl>
                  <dt>周波数</dt>
                  <dd>{{ sg90_pwm.frequency }}[Hz]</dd>
                </dl>
              </dd>
            </dl>
            <dl v-for="item in sg90_pwm.channels">
              <dt>ch. {{ item.ch }}</dt>
              <dd>
                <dl>
                  <dt>turnon</dt>
                  <dd>{{ item.turnon }}</dd>
                  <dt>turnoff</dt>
                  <dd>
                    <dl>
                      <dt>start</dt>
                      <dd>{{ item.turnoff.start }}</dd>
                      <dt>end</dt>
                      <dd>{{ item.turnoff.end }}</dd>
                    </dl>
                  </dd>
                </dl>
              </dd>
            </dl>
          </div>
        </section>
        <section>
          <h2>カメラマウントコントロール</h2>
          <ul>
            <li>四角の領域を指でタッチ/マウスクリックしながら動かします</li>
          </ul>
          <div>
            <div class="ope_panel" style="width:60%;" ref="ope_panel" v-on:mousemove="onMouseMove" v-on:mousedown="onMouseDown"></div>
            <dl>
              <dt>カーソル位置</dt>
              <dd>{{ cursor.x }} , {{ cursor.y }}</dd>
              <dt>turn off</dt>
              <dd>{{ turnoff[0] }} , {{ turnoff[1] }}</dd>
              <dt>操作中</dt>
              <dd>{{ dragging }}</dd>
            </dl>
          </div>
        </section>
        <section class="log">
          <h2>WebSocket応答</h2>
          <ul v-for="subitem in logs">
            <li>{{ subitem }}</li>
          </ul>
        </section>
        <button type="button" v-on:click="clearParam()">SG90設定(ローカルストレージ)クリア</button>
        <section class="clear">
          <small>&copy; 2018 日記というほどでも denor.jp</small>
        </section>
      </div>
      <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
      <script>
        // 実際にWebSocketで送信するPWM制御データ
        var PwmState = function () {
          this.data = {
            'resolution': 4096,
            'frequency': null,
            'channels': [
              { 'ch': 0, 'name': 'Ch.0', 'turnon': null, 'turnoff': null },
              { 'ch': 1, 'name': 'Ch.1', 'turnon': null, 'turnoff': null }
            ]
          }
        }
        PwmState.prototype.calcTurnStep = function(start, end, pos) {
          pos = pos < 0 ? 0 : pos;
          pos = pos > 1  ? 1 : pos;
          if (end < start) {
            return Number.parseInt(end + (start - end) * pos);
          } else {
            return Number.parseInt(start + (end - start) * pos);
          }
        }
        var pwm = new PwmState(); //PWM制御データのインスタンス
        var app1 = new Vue({
          el: '#app',
          data: {
            'pwmdata': pwm.data,  //インスタンスへの参照
            'cursor' : {'x' : 0, 'y' : 0},
            'pos': [0, 0],
            'turnoff' : [0, 0],
            'dragging': false,
            //pwm_testページで設定した可動範囲パラメータ
            //設定された範囲を超えないようにPWM制御データを作成して送信
            'sg90_pwm': {
              'frequency': null,
              'channels': [
                { 'ch': 0, 'turnon': null, 'turnoff': { 'start': null, 'end': null } },
                { 'ch': 1, 'turnon': null, 'turnoff': { 'start': null, 'end': null } },
              ]
            },
            timerId: null,
            timerInterval: 100,
            'logs': []
          },
          methods: {
            createPwmState: function() {
              var src = this.$data.sg90_pwm;
              var dst = this.$data.pwmdata;
    
              dst.frequency = Number.parseInt(src.frequency);
              for(var i=0; i<dst.channels.length; i++) {
                dst.channels[i].turnon = Number.parseInt(src.channels[i].turnon);
                this.$data.turnoff[i] = dst.channels[i].turnoff 
                  = pwm.calcTurnStep(Number.parseInt(src.channels[i].turnoff.start),
                  Number.parseInt(src.channels[i].turnoff.end),
                      this.$data.pos[i] / 100);
              }
            },
            sendEventToWs: function () {
              if (this.$data.timerId) return;
              var _this = this;
              this.$data.timerId = setTimeout(function () {
                socket.send(JSON.stringify(_this.$data.pwmdata));
                clearTimeout(_this.$data.timerId);
                _this.$data.timerId = null;
              }, this.$data.timerInterval);
            },
            onMouseMove: function (ev) {
              if (!this.$data.dragging) return;
              this.$data.cursor.x = ev.offsetX;
              this.$data.cursor.y = ev.offsetY;
              this.$data.pos[0] = ev.offsetX / ev.target.clientWidth * 100;
              this.$data.pos[1] = ev.offsetY / ev.target.clientHeight * 100;
              this.createPwmState();
              this.sendEventToWs();
            },
            onMouseDown: function (ev) {
              this.$data.dragging = true;
            },
            onMouseUp: function (ev) {
              this.$data.dragging = false;
            },
            addLogs: function (log) {
              var logs = this.$data.logs;
              logs.unshift(log);
              var maxlog = 10;
              if (logs.length > maxlog) {
                logs.splice(maxlog, logs.length - maxlog);
              }
            },
            loadPwmParam: function () {
              var name = 'sg90_pwm';
              if (!localStorage[name]) return false;
              this.$data.sg90_pwm = JSON.parse(localStorage[name]);
              return true;
            },
            clearParam: function () {
              localStorage.clear();
            }
          },
          mounted: function () {
            var _this = this;
            // 操作パネルを正方形に変形します
            var resizeOpePanel = function (ev) {
              var pcWidth = Number.parseInt(_this.$refs.ope_panel.style.width);
              var scFullWidth = Number.parseInt(window.innerWidth);
              _this.$refs.ope_panel.style.height = (pcWidth * scFullWidth / 100).toString() + "px";
            }
            resizeOpePanel();
            window.addEventListener('resize', resizeOpePanel);
            window.addEventListener('mouseup', this.onMouseUp);
    
            if (!this.loadPwmParam()) {
              alert('カメラマウントの制御に必要なパラメータが入力されていません。\nお手数ですが「pwm制御パラメータ調整」ページにて、最適な値を調べて入力して下さい。')
            }
    
            socket = new WebSocket('ws://' + window.location.hostname + ':3001/');
            var _this = this;
            socket.addEventListener('open', function (event) {
              _this.addLogs('接続しました');
            });
            socket.addEventListener('message', function (event) {
              _this.addLogs('Raspi応答:' + event.data);
            });
    
            this.addLogs(this.$data.timerInterval + "msごとにPWM制御データを送信します")
          }
        });
      </script>
    </body>
    
    </html>

views/pwm_test.ejsファイル作成

  1. pwm_test.ejsファイルを作成します。
    vi views/pwm_test.ejs

    モーターの可動範囲を調べる画面になります。

    <!DOCTYPE html>
    <html lang="ja">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>PWM制御パラメータ調整</title>
      <link rel='stylesheet' href='/stylesheets/style.css' />
      <style>
        body { margin: 0.5em; border: 0; padding: 0.5em;}
        dt { background-color: aliceblue; }
        canvas { border: 1px solid aliceblue; }
        @media screen and (min-width:760px) { 
          .clear { clear: both; }
          .log { width: 50%; }
          .setting, .graph, .log  { float: left; }
        }    
      </style>
    </head>
    
    <body>
      <a href="/">SG90カメラマウント制御</a>
      <h1>PWM制御パラメータ調整</h1>
      <div id="app">
        <section>
          <h2>カメラマウントで使用するパラメータ</h2>
          <dl>
            <h3>共通</h3>
            <dt>周波数</dt>
            <dd><input type="number" v-model="sg90_pwm.frequency"></dd>
            <dt>Ch.0 turnon</dt>
          </dl>
          <dl v-for="item in sg90_pwm.channels">
            <h3>Ch.{{ item.ch }}</h3>
            <dt>turon</dt>
            <dd><input type="number" v-model="item.turnon"></dd>
            <dt>turoff start</dt>
            <dd><input type="number" v-model="item.turnoff.start"></dd>
            <dt>turoff end</dt>
            <dd><input type="number" v-model="item.turnoff.end"></dd>
          </dl>
        </section>
        <hr>
        <section class="setting">
          <h2>こちらで良さそうなパラメータを探します</h2>
          <h3>共通</h3>
          <dl>
            <dt>Frequency:周波数</dt>
            <dd><input type="number" min="24" max="1526" v-model="pwmdata.frequency">Hz
              <input type="range" min="24" max="1526" step="1" v-model="pwmdata.frequency">(24-1526)</dd>
          </dl>
          <template v-for="item in pwmdata.channels">
            <h3>Ch.{{ item.ch }}:{{ item.name }}</h3>
            <dl>
              <dt>turn on steps:ONにするタイミング</dt>
              <dd>
                <input type="number" v-model="item.turnon" min="0" max="4095">
                <input type="range" min="0" v-model="item.turnon" max="4095" step="1">(0-4095)
              </dd>
              <dt>turn off steps:OFFにするタイミング</dt>
              <dd>
                <input type="number" v-model="item.turnoff" min="0" max="4095">
                <input type="range" v-model="item.turnoff" min="0" max="4095" step="1">(0-4095)
              </dd>
            </dl>
          </template>
          <h2>パラメータ情報</h2>
          <dl>
            <dt>1周期の時間</dt>
            <dd>{{ period_sec | usec | toFixed }}μs = {{ period_sec | msec | toFixed }}ms</dd>
          </dl>
          <template v-for="item in pwmdata.channels">
            <h3>Ch.{{ item.ch }}:{{ item.name }}</h3>
            <dl>
              <dt>ON時間</dt>
              <dd>{{ calcOnPeriodSec(item) | msec | toFixed }} ms</dd>
              <dt>OFF時間</dt>
              <dd>{{ calcOffPeriodSec(item) | msec | toFixed }} ms</dd>
              <dt>Duty Cycle:1周期のONの割合</dt>
              <dd>{{ calcDutyCycle(item) * 100 | toFixed }}%</dd>
            </dl>
          </template>
        </section>
        <section class="graph">
          <canvas ref="canvas_graph" width="480px" height="320px"></canvas>
        </section>
        <section class="log">
          <h2>WebSocket応答</h2>
          <ul v-for="subitem in logs">
            <li>{{ subitem }}</li>
          </ul>
        </section>
        <section class="clear">
          <small>&copy; 2018 日記というほどでも denor.jp</small>
        </section>
      </div>
      <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
      <script src="/javascripts/pwm_graph2.js"></script>
      <script>
        // こちらはテスト的に動かす場合の実験パラメータ
        var PwmState = function () {
          this.data = {
            'resolution': 4096,
            'frequency': 50,
            'channels': [
              { 'ch': '0', 'name': 'Ch.0', 'turnon': '0', 'turnoff': '1' },
              { 'ch': '1', 'name': 'Ch.1', 'turnon': '0', 'turnoff': '1' }
            ]
          }
        }
        PwmState.prototype.calcCycleSec = function () {
          return (1.0 / Number.parseInt(this.data.frequency));
        }
        PwmState.prototype.calcOnPeriod = function (ton, toff) {
          var turnon = Number.parseInt(ton);
          var turnoff = Number.parseInt(toff);
          var res = Number.parseInt(this.data.resolution);
          return (turnon < turnoff) ? (turnoff - turnon) : (turnoff + res - turnon);
        }
        PwmState.prototype.calcOffPeriod = function (ton, toff) {
          return Number.parseInt(this.data.resolution) - this.calcOnPeriod(ton, toff)
        }
        PwmState.prototype.calcDutyCycle = function (ton, toff) {
          return this.calcOnPeriod(ton, toff) / Number.parseInt(this.data.resolution);
        }
        PwmState.prototype.calcOnPeriodSec = function (ton, toff) {
          return this.calcCycleSec() * (this.calcOnPeriod(ton, toff) / Number.parseInt(this.data.resolution));
        }
        PwmState.prototype.calcOffPeriodSec = function (ton, toff) {
          return this.calcCycleSec() * (this.calcOffPeriod(ton, toff) / Number.parseInt(this.data.resolution));
        }
    
        var pwm = new PwmState();
        var graph = new PwmGraph(pwm);
        var socket;
        var app1 = new Vue({
          el: '#app',
          data: {
            'pwmdata': pwm.data,
            timerId: null,
            timerInterval: 100,
            logs: [],
            // こちらはSG90カメラマウントページで動かす場合の決定パラメータ
            'sg90_pwm': {
              'frequency': null,
              'channels': [
                { 'ch': 0, 'turnon': null, 'turnoff': { 'start': null, 'end': null } },
                { 'ch': 1, 'turnon': null, 'turnoff': { 'start': null, 'end': null } },
              ]
            },
            'sg90_pwm_tmpl': null,
          },
          methods: {
            SendEventToWs: function (interval) {
              if (this.$data.timerId) return;
              var _this = this;
              this.$data.timerId = setTimeout(function () {
                socket.send(JSON.stringify(_this.$data.pwmdata));
                clearTimeout(_this.$data.timerId);
                _this.$data.timerId = null;
              }, interval);
            },
            calcDutyCycle: function (item) {
              return pwm.calcDutyCycle(item.turnon, item.turnoff);
            },
            calcOnPeriodSec: function (item) {
              return pwm.calcOnPeriodSec(item.turnon, item.turnoff);
            },
            calcOffPeriodSec: function (item) {
              return pwm.calcOffPeriodSec(item.turnon, item.turnoff);
            },
            addLogs: function (log) {
              var logs = this.$data.logs;
              logs.unshift(log);
              var maxlog = 10;
              if (logs.length > maxlog) {
                logs.splice(maxlog, logs.length - maxlog);
              }
            },
            loadPwmParam: function () {
              var name = 'sg90_pwm';
              if (!localStorage[name]) return false;
              this.$data.sg90_pwm = JSON.parse(localStorage[name]);
              return true;
            },
            savePwmParam: function () {
              var name = 'sg90_pwm';
              localStorage[name] = JSON.stringify(this.$data.sg90_pwm);
            }
          },
          watch: {
            'pwmdata': {
              handler: function (newVal, oldVal) {
                graph.draw(this.$refs.canvas_graph);
                this.SendEventToWs(this.$data.timerInterval);
              },
              deep: true
            },
            'sg90_pwm': {
              handler: function (newVal, oldVal) {
                this.savePwmParam();
              },
              deep: true
            }
          },
          computed: {
            period_sec: function () {
              return pwm.calcCycleSec();
            }
          },
          mounted: function () {
            socket = new WebSocket('ws://' + window.location.hostname + ':3001/');
            var _this = this;
            socket.addEventListener('open', function (event) {
              _this.addLogs('接続しました');
            });
            socket.addEventListener('message', function (event) {
              _this.addLogs('Raspi応答:' + event.data);
            });
    
            graph.draw(this.$refs.canvas_graph, pwm);
            this.addLogs(this.$data.timerInterval + "msごとにPWM制御データを送信します")
            this.SendEventToWs(this.$data.timerInterval);
    
            this.$data.sg90_pwm_tmpl = JSON.parse(JSON.stringify(this.$data.sg90_pwm));
            this.loadPwmParam();
          },
          filters: {
            toFixed: function (val) {
              return val.toFixed(2);
            },
            msec: function (val) {
              return val * 1000;
            },
            usec: function (val) {
              return val * 1000000;
            }
          }
        })
      </script>
    </body>
    
    </html>

public/javascripts/pwm_graph2.jsファイル作成

  1. pwm_grasph2.jsファイルを作成します。
    vi public/javascripts/pwm_graph2.js

    モーターの可動範囲を調べる画面のグラフを描画します。

    var PwmGraph = function (pwm) {
      this.ctx = null;
      this.pwm = pwm;
      this.client = { width: 0, height: 0 };
      this.margin = 40; //px
      this.graph = { width: 0, height: 0 };
      this.normalized = { width: 100, height: 0 };
      // this.channels = 3;
      this.channels = 2;
      this.padding = 3;
    }
    
    PwmGraph.prototype.draw = function (cvs) {
      this.ctx = cvs.getContext('2d');
      this.client.width = cvs.width;
      this.client.height = cvs.height;
      this.clear();
      this.graph.width = this.client.width - this.margin * 2;
      this.graph.height = this.client.height - this.margin * 2;
    
      //座標軸描画
      this.drawAxis();
    
      //パラメータ描画
      this.drawParams();
    
      //グラフ本体描画
      this.drawGraph(1, pwm.data.channels[0], 'green');
      this.drawGraph(0, pwm.data.channels[1], 'blue');
    }
    
    PwmGraph.prototype.clear = function () {
      var ctx = this.ctx;
      ctx.save();
      this.resetTransform();
      ctx.fillStyle = 'white';
      ctx.fillRect(0, 0, this.client.width, this.client.height);
      ctx.restore();
    }
    
    PwmGraph.prototype.setTransformNormalized = function () {
      // 座標変換 Y軸反転、グラフの原点を描画の原点に合わせ
      // さらにグラフ横軸の座標を100に正規化します
      var ctx = this.ctx;
      this.normalized.height = this.normalized.width * this.graph.height / this.graph.width;
      ctx.setTransform(this.graph.width / this.normalized.width, 0,
        0, -this.graph.width / this.normalized.width,
        this.margin, this.client.height - this.margin);
    }
    
    PwmGraph.prototype.resetTransform = function () {
      this.ctx.setTransform(1, 0, 0, 1, 0, 0);
    }
    
    PwmGraph.prototype.drawAxis = function () {
      var ctx = this.ctx;
      ctx.beginPath();
      this.setTransformNormalized();
      ctx.lineWidth = 1;
      ctx.lineCap = 'butt';
      ctx.moveTo(0, 0);
      ctx.lineTo(0, this.normalized.height);
      ctx.moveTo(this.normalized.width, 0);
      ctx.lineTo(this.normalized.width, this.normalized.height);
      var xScale = 10;
      for (var i = 1; i < xScale; i++) {
        var xStep = this.normalized.width / xScale;
        ctx.moveTo(xStep * i, 0);
        ctx.lineTo(xStep * i, this.padding);
      }
      this.resetTransform();
      var res = Number.parseInt(this.pwm.data.resolution);
      ctx.font = "16px serif";
      ctx.fillText("0", this.margin - 14, this.margin + 14);
      ctx.fillText((res - 1).toString(), this.client.width - this.margin - 40, this.margin + 14);
      ctx.strokeStyle = 'black';
      ctx.stroke();
    }
    
    PwmGraph.prototype.drawParams = function () {
      var ctx = this.ctx;
      ctx.beginPath();
      this.resetTransform();
      ctx.font = "20px serif";
      ctx.fillText(
        (this.pwm.calcCycleSec() * 1000).toFixed(3).toString() + 'ms   ' +
        (this.pwm.calcCycleSec() * 1000000).toFixed(3).toString() + 'μs',
        this.client.width - 230, this.client.height - 20);
      ctx.strokeStyle = 'black';
      ctx.stroke();
    }
    
    PwmGraph.prototype.drawGraph = function (index, channel, color) {
      var ctx = this.ctx;
      var amp = (this.normalized.height - this.padding) / this.channels;
      var minY = this.padding * 2 + amp * index;
      var maxY = minY + amp - this.padding;
      var turnon = Number.parseInt(channel.turnon);
      var turnoff = Number.parseInt(channel.turnoff);
    
      ctx.beginPath();
      this.setTransformNormalized();
      ctx.lineWidth = 1.2;
      ctx.strokeStyle = color;
      if (turnon > turnoff) {
        var tmp = minY;
        minY = maxY;
        maxY = tmp;
        tmp = turnon;
        turnon = turnoff;
        turnoff = tmp;
      }
      var res = Number.parseInt(this.pwm.data.resolution);
      var tonX = turnon / res * this.normalized.width;
      var toffX = turnoff / res * this.normalized.width;
      ctx.moveTo(0, minY);
      ctx.lineTo(tonX, minY);
      ctx.lineTo(tonX, maxY);
      ctx.lineTo(toffX, maxY);
      ctx.lineTo(toffX, minY);
      ctx.lineTo(this.normalized.width, minY);
      ctx.stroke();
    }
    //&copy; 2018 日記というほどでも denor.jp

ws-event-handler.jsファイル作成

  1. 最後にws-event-handler.jsファイルを作成します。
    vi ws-event-handler.js

    pca9685モジュールを使用して、PWM制御基板に命令を送る内容です。

    var i2cBus = require("i2c-bus");
    var Pca9685Driver = require("pca9685").Pca9685Driver;
    
    var pwm = null;
    var prevFrequency = 0;
    
    var WsEventHandler = function() {
        this.client_cnt = 0;
    }
    
    WsEventHandler.prototype.connected = function(wss) {
        this.client_cnt ++;
        if(this.client_cnt > 1)  return;
        // 以下接続開始時の処理
    
        prevFrequency = 0;
    }
    
    var setPluseRange = function(wss, pwm, channel) {
    
        if (! channel) return;
        
        wss.sendAllClients("setPulseRange開始:" + JSON.stringify(channel));
        pwm.setPulseRange(Number.parseInt(channel.ch),
    	Number.parseInt(channel.turnon),
    	Number.parseInt(channel.turnoff),
    	function(err) {
            if (err) {
                wss.sendAllClients("CH" + channel.ch + ":setPulseRangeエラー");
            } else {
                wss.sendAllClients("CH" + channel.ch + ":setPulseRange完了");
            }
        });
    }
    
    WsEventHandler.prototype.handleEvent = function(wss, data) {
        if(! data) return;
        //イベントに応じた処理実行
    
        // 周波数が変わった場合、pwm制御オブジェクトを再作成
        var newFrequency = Number.parseInt(data.frequency);
        if (prevFrequency != newFrequency &&
            24 <= newFrequency && 1526 >= newFrequency) {
            var options = {
                i2c: i2cBus.openSync(1),
                address: 0x40,
                frequency: newFrequency,
                debug: false
            };
            wss.sendAllClients("PCA9685初期化開始:" + JSON.stringify(data));
            pwm = new Pca9685Driver(options, function(err) {
                if (err) {
                    wss.sendAllClients("PCA9685:初期化エラー");
                } else {
                    wss.sendAllClients("PCA9685:初期化完了");
                }
            });
            prevFrequency = newFrequency;
        }
        for(var i=0; i<3; i++) {
            setPluseRange(wss, pwm, data.channels[i])
        }
    }
    
    WsEventHandler.prototype.closed = function(wss) {
        this.client_cnt --;
        if(this.client_cnt > 0)  return;
        // 以下接続終了時の処理
    
    }
    
    module.exports = WsEventHandler
    //&copy; 2018 日記というほどでも denor.jp

以上で準備が完了しました。


サーボモーターの設定手順

作ってから気づいたのですが。上記のソースコードは、タッチ操作ではなく、マウス操作が必要になります。PC等、マウス操作が可能なWebブラウザからアクセスして下さい。

  1. 作成したSG90CamMntプロジェクトを実行すると
    DEBUG=SG90CamMnt:* npm start

    スマートフォンやWebブラウザで電動雲台の制御画面にアクセスできるようになります。

    http://<Raspberry PiのIPアドレス>:3000
    

  2. モーターのパラメータを入れていない場合、コントロール画面で警告が表示されます。
  3. 画面左上のリンクから、パラメータの入力画面へ移動します。
  4. 画面を下にスクロールすると、周波数やturn onステップの入力画面になります。
    • SG90の場合、仕様上の駆動周波数は50Hzになります。
    • turn onは0のままで問題無いと思います。
    • turn offを変更して、モーターの動く範囲を調べます。チルトの場合、モーターを動かしすぎて、雲台を壊さないようにご注意下さい。
  5. 最適なパラメータがわかったら、画面上部の「カメラマウントで使用するパラメータ」欄に入力します。おうちの環境の場合、チルト用のモーターは、turn offが240から413くらいが丁度よい感じでした。
  6. 画面左上のリンクから「SG90カメラマウント制御」画面に戻ると、実際に電動雲台を動かす事ができます。画面下部の四角の領域で、マウスボタンを押しながらマウスを動かすと、カメラが上下左右に動くかと思います。
  7. 制御パラメータは、Webブラウザのローカルストレージに格納されます。ローカルストレージをクリアしたい場合、「SG90設定(ローカルストレージ)クリア」ボタンを押して消去することができます。

HTMLのマウスイベントでは、タッチパッドの操作は取れないのですね。うーむ。

本当はスマートフォンでタッチパッドで動かそうと思っていたのですが。

そのあたりは、使用するスマートフォンの機種とWebブラウザに合わせて、HTMLを少し変更すれば対応できそうです。

今回は雲台を動かしただけで、雲台にまだカメラを取り付けていません。

そのうち、何か取付用の部品を3Dプリンタで作成しようと思います。

スポンサーリンク

フォローする

スポンサーリンク

コメント

  1. HDさん より:

    突然のご連絡失礼いたします。
    大学 理学部4年のHDと申します。
    卒業研究でhide様の2018年11月20日のブログ「電動雲台をWebブラウザからウニョウニョ操作するには」を参考にさせていただいたのですが、私自身プログラミングが初心者であるためタッチパッドで動かすためのHTMLの変更の手順がわからず、差し支えなければご教授いただくことが可能か伺いたく連絡いたしました。
    よろしくお願いいたします。

    • hide より:

      誠に勝手ながらお名前を変更させて頂きました。
      はじめまして。コメント頂きまして、誠にありがとうございます。

      記事の内容は、マウスイベントを取得する内容になっておりますが。
      マウスではなく、タッチパッドにしたい、というお問い合わせと認識致しました。

      マウスのイベント取得部分は、次の箇所になります。

      1. views/index.ejs
        1. 90行目divタグ
        2. 179行目onMouseMoveイベントハンドラ
        3. 188行目onMouseDownイベントハンドラ
        4. 191行目onMouseUpイベントハンドラ

      上記のonMouseMoveイベントハンドラにて、最終的にthis.$dataという変数に、マウスの移動量をセットしております。
      ドラッグ操作の開始・終了の判定に、onMouseDown開始、onMouseUp終了という判定処理が御座います。

      ではマウスではなく、タッチパッドの操作にするには?ですが。
      変更が必要な箇所は、上述のindex.ejsファイルの4箇所で合っていると思います。

      私はタッチイベントが理解できていませんため、
      ここからは、もし私が作るなら、どのような工程を進むか、についてお伝えします。

      90行目のdivタグは、マウスのイベントを取得する領域(HTMLのdivタグ)という位置づけでした。
      タッチパッドの場合、divタグのままで良いのか?タグを変更する必要があるか?
      をまず調査頂ければと思います。

      次に、イベントについてですが。
      マウスの場合、onMouseDown→ボタンを押す →onMouseMove→マウスを動かす→onMouseUp→ボタンを離す
      の判定処理を、179行目から193行目まで行っておりました。

      タッチパッドの場合は、タッチ、スワイプ、フリック等、マウスとイベントが異なると思います。
      具体的に、どのような操作で、どのようなイベントが発生するか?
      を調査頂く必要が御座います。

      調査の対象となる資料は、HTML(HyperText Markup Language)の仕様書
      特にタッチイベント関連の資料になると思います。

      Web上で検索させて頂いたURLを記載致します。

      https://developer.mozilla.org/ja/docs/Web/API/Touch_events

      https://web-breeze.net/js-touch-event/

      タッチイベントを扱ったサンプルを参考に、別途HTMLファイルを作成して
      動作対象のブラウザで動かしてみることをお勧め致します。

      具体的にどのようなイベントが発生する、ということが理解できましたら、
      index.ejsファイルを改修してみる、という流れがシンプルかと思います。

      参考になりますでしょうか?
      何か御座いましたら、またいつでもコメント頂ければと思います。

      • HD より:

        非常に丁寧な回答に加えて参考文献まで載せていただきありがとうございました。参考にさせていただきます。

  2. HD より:

    質問失礼します。
    90行目のdivタグでマウスイベントを取得しているとのことでしたが、onMouseUpはこの中に含まれないのでしょうか?
    イベントの導入はaddEventListenerで行うものだと認識していたのですが、divタグにて定義しているということでしょうか?

    また、222行目の操作パネルの変形でaddEventListenerでonMouseUpのイベント処理を行っているのはどういった意図があるのでしょうか?

    お忙しいところ恐れ入りますが、ご教授いただけますと幸いです。よろしくお願いいたします。

    • hide より:

      コメント頂きまして、誠にありがとうございます。

      onMouseUp関連のイベントですが。

      90行目のonMouseUpという関数が、イベントハンドラと呼ばれる関数になります。
      mouseupイベントが発生した時に、呼ばれる関数の本体になります。
      関数の中では、draggingという変数にfalseをセットして「ドラッグが終わったよ!=ドラッグ中ではない」という状態を作っています。

      そして222行目ですが。
      これは、windowオブジェクトに対して「mouseupのイベントが発生したら、this.onMouseUp関数をコールして下さい」と依頼しています。
      222行目はmounted:関数になりますが、これはWebページが表示される最初に呼ばれる関数、と考えて頂ければ大丈夫です。

      まとめますと。
      Webページの表示開始→mouted関数の中で「mouseupイベントが発生したら、onMouseUp関数をコールするように登録」→Webページが表示される。
      ここで一段落。
      mouseupイベントが発生する→登録しておいたonMouseUp関数がコールされる→dragging=false
      以下、Webページ上でイベントが発生すると繰り返し。

      という流れになります。

      90行目、マウスイベントを取得するdivタグですが。
      v-on:mousemove=”onMouseMove” v-on:mousedown=”onMouseDown”属性をセットしています。
      つまり、mousemoveとmousedownのイベントは、それぞれ「onMousMoveとonMouseDown関数をコールしてね」と依頼しています。

      しかし、mouseupイベントは、どこで発生するかわからないため。
      上述の222行目でwindowオブジェクトに依頼しています。

      なぜ、そのような作りなのかは。
      90行目でonMouseUpイベントを取得するように、改造するとわかるかもしれません。

      以上のご説明で、参考になりますでしょうか?
      またいつでもコメント頂ければ幸いです。

  3. HD より:

    回答いただきありがとうございます。
    「イベントが発生したら関数をコールする」という命令文の意味は共通ですが、依頼先が異なるといった解釈でよろしいのでしょうか?

    • hide より:

      コメント頂きまして、誠にありがとうございます。

      はい、まさにその通りです。

      このあたりの分野は、WebブラウザがHTMLを読み込んだ状態の
      DOM(Document Object Model)仕様に関する内容になりますが。

      さしあたり、依頼先は次の3種類と考えて頂ければ大丈夫かと思います。

      1. documentオブジェクト。ドキュメント全体=BODYタグ。
      2. 個別のタグ=エレメント。ドキュメントの中のタグ。divタグ等様々。
      3. windowオブジェクト。これはWebブラウザの画面。ドキュメントを表示している画面ということです。

      この3種類で整理すると、イメージしやすいかもしれません。

      どのイベントを、どこで=上記の3つのどれで、拾いたいか、で設計する感じでしょうか。