電動雲台を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プリンタで作成しようと思います。

スポンサーリンク

フォローする

スポンサーリンク