ロボット研究開発、ソフトウェア開発、コンテンツ制作配信

Arduino+ラズパイ3で金魚の自動餌やり機をリモート操作できるようにした

こんにちは、koheiです。

以前、Arduinoを使って金魚の自動餌やり機を作成しましたが、今回は、ラズパイ3を組み合わせて、スマホ(Android)からリモートで操作できるようにしました。
Aruduino単体では、Wifiモジュールがついていないため、無線通信ができません。ラズパイ3はWifiがついているので、無線通信できます。多分ラズパイだけでも金魚の餌やり機が作れると思いますが、せっかくArduinoで餌やり機を作っていたので、今回はラズパイ+Arduinoでリモート操作を実現してみました。

備忘録としてまとめておきます。

スポンサーリンク

用意したもの

以前の金魚餌やり機から追加したものは、ラズパイ3B+、Android端末です。

・ラズパイ3B+
・Androidスマホ

前回の金魚餌やり機の記事は、以下を参考にしてください。

はじめての電子工作 Arduinoで金魚の自動餌やり機を自作してみた

回路図

回路図は前回の金魚餌やり機の回路にUSBケーブルでArduinoとラズパイをつなぐだけです。(Arduino側の回路図は前回の記事を参照ください)

動作仕様

スマホで、ONボタンを押すと、サーボモーターが動き、OFFを押すとサーボモートが止まるシンプルな仕様です。

システム構成

さて、スマホ(Android端末)からのリモート操作でどうやってサーボを動かすのか?
ざっくり図に書くと以下になります。
・スマホとラズパイ間の通信は、「MQTT」というプロトコルを使っています。
・ラズパイとArduino間はシリアル通信でやり取りします。

1.Arduinoとラズパイ間のシリアル通信を構築

まずは、Arduinoとラズパイ間のシリアル通信を実装していきます。
シリアル通信の仕様は、シンプルに[on]or[off]を通知してモーターを動かす、止めるという仕様にしました。

・on:モーター動かす (ラズパイ→Arduino)
・off:モーター止める(ラズパイ→Arduino)

1-1. Arduino側プログラム

Arduino側のプログラムはシリアル通信でデータを受信し、データがon/offだったらモーターを動かす、止めるというプログラムです。
以下のように実装しました。

■kingyo_esa_program(ver2.0)

#include <MsTimer2.h>

/* Sweep
 by BARRAGAN <http://barraganstudio.com>
 This example code is in the public domain.

 modified 8 Nov 2013
 by Scott Fitzgerald
 http://www.arduino.cc/en/Tutorial/Sweep
*/
#include <Servo.h>
#include <pt.h>

Servo myservo;  // create servo object to control a servo
// twelve servo objects can be created on most boards

int pos = 0;    // variable to store the servo position

char data[10]; // 文字列格納用
int i = 0; // 文字数カウンタ

volatile boolean sw=false;
int ct=0;
int pt=0;

volatile boolean timer_mode=false;
volatile boolean tsw=false;
int long_key_t=0;


//タイマー関連
int sec_cnt=0;
int min_cnt=0;
int hour_cnt=0;

static struct pt pt1, pt2;
        // pos init
    int p = 180;
    int kaiten = 0;

#define PT_WAIT(pt, timestamp, usec) PT_WAIT_UNTIL(pt, millis() - *timestamp > usec);*timestamp = millis();



void setup() {
  myservo.attach(9);  // attaches the servo on pin 9 to the servo object
  pinMode(2, INPUT_PULLUP); //タクトスイッチの設定
  pinMode(13,OUTPUT);
  pinMode(12,OUTPUT);
  attachInterrupt(0, chgsw, CHANGE);
  Serial.begin(9600);
  myservo.write(180);              // tell servo to go to position in variable 'pos'

  MsTimer2::set(1000, timecnt);
  MsTimer2::start();

  PT_INIT(&pt1);
  PT_INIT(&pt2);

}


void timecnt() {
  if(!timer_mode){
    return;
  }
  //1000ms->1s毎に呼び出される
  sec_cnt++;
  Serial.print(hour_cnt);
  Serial.print("h");
  Serial.print(min_cnt);
  Serial.print("min");
  Serial.print(sec_cnt);
  Serial.println("sec");
  if(sec_cnt > 59)
  {
    //tsw=true;
    min_cnt++;
    sec_cnt=0;
  }
  if(min_cnt > 59)
  {
    hour_cnt++;
    min_cnt=0;
  }
  if( hour_cnt > 8 )
  {
    tsw=true;
    sec_cnt=0;
    min_cnt=0;
    hour_cnt=0;
  }
}


void loop() {

  thread1(&pt1);  // Serial Command Servo action
  
  //key switch action
/*
  if( sw )
  {
    motor_action();
  }
  */
  // timer action
  if( tsw ){
    motor_action();
    tsw=0;
  }
}

void motor_action() {
  for (pos = 180; pos >=80; pos -= 1) {
  //for (pos = 0; pos <= 120; pos += 1) { // goes from 0 degrees to 180 degrees
    // in steps of 1 degree
    myservo.write(pos);              // tell servo to go to position in variable 'pos'
    delay(100);                       // waits 15ms for the servo to reach the position
  }
//  for (pos = 120; pos >= 0; pos -= 1) { // goes from 180 degrees to 0 degrees
  for (pos = 80; pos <= 180; pos += 1) {
    myservo.write(pos);              // tell servo to go to position in variable 'pos'
    delay(100);                       // waits 15ms for the servo to reach the position
  }
}

void chgsw() {
  ct = millis();
  if((ct-pt)>300) //チャタリング対応
  {  
    //最初のプレス
    if(digitalRead(2)==LOW) {
        //長押しONタイマー保存
        long_key_t = ct;  
    }
    //リリース
    else{
     //長押しタイマー判定
     if( (ct-long_key_t) > 3000 ){
      timer_mode = true;
      sec_cnt=0;
      min_cnt=0;
      hour_cnt=0;
      Serial.println("TIMER MODE");
      digitalWrite(12,HIGH);
     }
     else{
      //タイマーモード中は、短押しでタイマーモード解除
      if(timer_mode){
        timer_mode=false;
        digitalWrite(12,LOW);
        return;
      }
       Serial.println("SHORTKEY");
       if(!sw) {
        sw = true;
        digitalWrite(13,HIGH);
        Serial.println("HIGH");
       }
       else {
         sw = false;
        digitalWrite(13,LOW);
         Serial.println("LOW");
       }
     }
    }
  }
  pt = ct;
}



// データ受信
String serialRead() {
  // データ受信したとき
  if(Serial.available() > 0) {
    // 1文字ずつ読み込み
    data[i] = Serial.read();
    // 文字数が10以上 or 終端文字なら終了
    if (i > 10 || data[i] == '\0') {
      //Serial.println("end");
      i = 0;
      memset(&data[0],0x00,10); // バッファクリア
    }
    else { i++; }
  }

  return String(data);
}

// Serial Thread(シリアルコマンドで、サーボを動作させる)
static int thread1(struct pt *pt) {
    static unsigned long timestamp = 0;
    
    PT_BEGIN(pt);
    while(true) {
      // thread1の中身
      PT_WAIT(pt, &timestamp, 100); // 100ms wait
      //Serial.println("Thread1");

      PT_WAIT_THREAD(pt, thread2(&pt2));  // thread2 wait //入力を待つ

      //Serial.println(sw);
      if( sw ) {
         if( kaiten == 0) {
           myservo.write(p);
           //Serial.println("servo_write");
           p -= 1;
           if( p == 80 ) {
            kaiten = 1;
           }
         }
         else if( kaiten == 1) {
           myservo.write(p);
           //Serial.println("servo_write");
           p += 1;
           if( p == 180) {
            kaiten = 0;
           }
         }
      }
    }
    PT_END(pt);
}

// Serial check thread
static int thread2(struct pt *pt) {
    static unsigned long timestamp = 0;
    PT_BEGIN(pt);
    //Serial.println("thread2");

    String cmd = serialRead();
    //Serial.println(cmd);
    if(cmd =="on") {
      sw = true;
      i = 0;
      memset(&data[0],0x00,10); // バッファクリア
      digitalWrite(13,HIGH);
      Serial.println("HIGH");
    }
    if(cmd =="off") {
      sw = false;
      i = 0;
      memset(&data[0],0x00,10); // バッファクリア
      digitalWrite(13,LOW);
      Serial.println("LOW");
    }
    PT_END(pt);
}
【コード解説】
・前回作成したArduinoだけで動く金魚餌やり機のプログラムをベースに作っています。
・シリアルデータを読むこむ関数は、175行目のserialRead()です。
・193行目のthread1関数が、シリアルコマンドを受けて、サーボモーターを動作させている処理です。
・228行目のthread2関数でシリアルデータで読み込んだデータの判定を行っています。

1-2. ラズパイ側のシリアルテストプログラム

ラズパイ側は、on/offを通知するプログラムになります。
とりあえず、テスト的に、コマンド画面から入力した文字をシリアルで通知するプログラムを組みます。
以下のように実装しました。

■arduino_serial_test.py

# -*- coding: utf-8 -*-
import serial

def main():

    ser = serial.Serial('/dev/ttyACM0', 9600 , timeout=1)
    while True:
        val=ser.readline()
        print(val)
        flag = raw_input()
        print("input="+flag)
        ser.write(flag+'\0')

        if(flag == 'q'): # qが入力されたら通信終了
            break;
    ser.close()

if __name__ == '__main__':
    main()
【コード解説】
・6行目は、シリアル通信するための設定です。
→/dev/ttyACM0は、自分が接続したUSBポートの名前を設定します。「dmesg」コマンドで確認できます。
・12行目でシリアルデータに書き込んでいます。

1-3. 動作確認

さて、早速シリアル通信がうまくいっているか動作確認してみましょう。
ラズパイ側で、作成したPythonテストプログラム「arduino_serial_test.py」を実行してください。
python arduino_serial_test.py

実行したら、onと入力してモーターが駆動、offでモーターが止まればOKです。

2. ラズパイとスマホ間のMQTT通信を実装

次に、ラズパイースマホ間のMQTTプロトコルを実装していきます。
通信仕様は、以下のようにしました。

トピック メッセージ 説明
mqtt/test motor_on サーボモーターを動かす
mqtt/test motor_off サーボモーターを止める

2-1. ラズパイ側

まずは、ラズパイ側にMQTTブローカーをインストールします。
今回は、mosquittoというソフトを使います。以下の記事を参考に早速ラズパイにインストールします。

mosquittoをインストールしたら、ラズパイ側にSubscribe処理を入れます。
Pahoというライブラリを使えば、簡単に実装できます。
以下の記事を参考にさせていただきました。

以下のような処理をPythonで実装します。
■arduino_serial_mqtt.py

# -*- coding: utf-8 -*-
import serial
import threading
import paho.mqtt.client as mqtt

host = '127.0.0.1'
port = 1883
keepalive = 60
topic = 'mqtt/test'
ser = serial.Serial('/dev/ttyACM0', 9600 , timeout=1)

def main():

    #ser = serial.Serial('/dev/ttyACM0', 9600 , timeout=1)
#    ser = serial.Serial('/dev/ttyACM0', 9600)
    while True:
        val=ser.readline()
        print(val)
        #flag = input()
        #flag = flag + "\0"
        #print flag
        #ser.write(flag.encode())
        # python2 ver
        flag = raw_input()
        print("input="+flag)
#        if flag == 'on':
#            ser.write("on\n")
#        elif flag == 'off':
#            ser.write("off\n")
        ser.write(flag+'\0')

        if(flag == 'q'): # qが入力されたら通信終了
            break;
    ser.close()

def mqtt_func():
    # MQTT SETTINGS
    client = mqtt.Client()
    client.on_connect = on_connect
    client.on_message = on_message
    client.connect(host, port, keepalive)
    client.loop_forever()

def on_connect(client, userdata, flags, rc):
    print('Connected with result code ' + str(rc))
    client.subscribe(topic)

def on_message(client, userdata, msg):
    global ser
    print(msg.topic + ' ' + str(msg.payload))
    if str(msg.payload) == 'motor_on':
        print("serial motor on")
        ser.write('on'+'\0')
    elif str(msg.payload) == 'motor_off':
        print("serial motor off")
        ser.write('off'+'\0')

if __name__ == '__main__':
    thread_1 = threading.Thread(target=main)
    thread_2 = threading.Thread(target=mqtt_func)

    thread_1.start()
    thread_2.start()
【コード解説】
・46行目でMQTTブローカーに対して、subscribeしています。
・48行目のon_message関数で、受信したメッセージの内容を判断し、シリアル通信(on/off)を通知しています。

2-2. スマホ側処理

スマホ側では、パブリッシャー処理を実装します。
こちらも、Pahoというライブラリを使って実装します。
以下の記事を参考にさせていただきました。

まずは、ライブラリファイルを以下のサイトからダウンロードし、Androidプロジェクトファイル内のlibsフォルダ内に格納します。

org.eclipse.paho.android.service-1.1.0.jar

org.eclipse.paho.client.mqttv3-1.1.0.jar

build.gradleに以下を追加しライブラリを読み込みます。

dependencies {
    //implementation fileTree(dir: 'libs', include: ['*.jar'])
    compile files('libs/org.eclipse.paho.android.service-1.1.0.jar')
    compile files('libs/org.eclipse.paho.client.mqttv3-1.1.0.jar')
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.2'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

AndroidManifest.xmlファイルに以下を追加します。
■AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.inomacreate.kingyo">

    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />


    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service android:name="org.eclipse.paho.android.service.MqttService" >
        </service>
    </application>

</manifest>

MainActivity.javaとactivity_main.xmlは以下のように実装しました。
■MainActivity.java

package com.inomacreate.kingyo;

import android.content.Context;
import android.content.Intent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;

import org.eclipse.paho.android.service.MqttAndroidClient;
import org.eclipse.paho.client.mqttv3.IMqttActionListener;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.IMqttToken;
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttPersistenceException;

public class MainActivity extends AppCompatActivity {

    private final String TAG = "Debug";
    private MqttAndroidClient mqttAndroidClient;

    private String ID = "hoge@github";
    private String PASS = "sango";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);


        mqttAndroidClient = new MqttAndroidClient(this, "tcp://xxx.xxx.xx.xx:1883", "d:lite:test:"){
            @Override
            public void onReceive(Context context, Intent intent) {
                super.onReceive(context, intent);

                Bundle data = intent.getExtras();

                String action = data.getString("MqttService.callbackAction");
                Object parcel = data.get("MqttService.PARCEL");
                String destinationName = data.getString("MqttService.destinationName");

                if(action.equals("messageArrived"))
                {
                    Log.d(TAG,destinationName + " " + parcel.toString());
                }

            }
        };

        try {
            MqttConnectOptions options = new MqttConnectOptions();
            options.setUserName(ID);
            options.setPassword(PASS.toCharArray());

            mqttAndroidClient.connect(options, null, new IMqttActionListener() {
                @Override
                public void onSuccess(IMqttToken iMqttToken) {
                    Log.d(TAG, "onSuccess");



//                    try {
//                        mqttAndroidClient.subscribe("hoge@github/#", 0);
//                        Log.d(TAG, "subscribe");
//                    } catch (MqttException e) {
//                        Log.d(TAG, e.toString());
//                    }
                }

                @Override
                public void onFailure(IMqttToken iMqttToken, Throwable throwable) {
                    Log.d(TAG, "onFailure");
                }
            });

        } catch (MqttException e) {
            Log.d(TAG, e.toString());
        }



        findViewById(R.id.button_on).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if(mqttAndroidClient == null)
                    return;

                try {
                    if(mqttAndroidClient.isConnected()) {
                        IMqttDeliveryToken token = mqttAndroidClient.publish("mqtt/test", "motor_on".getBytes(), 0, true);
                    }
                } catch (MqttPersistenceException e) {
                    Log.d(TAG,e.toString());
                } catch (MqttException e) {
                    Log.d(TAG,e.toString());
                }
            }
        });

        findViewById(R.id.button_off).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if(mqttAndroidClient == null)
                    return;

                try {
                    if(mqttAndroidClient.isConnected()) {
                        IMqttDeliveryToken token = mqttAndroidClient.publish("mqtt/test", "motor_off".getBytes(), 0, true);
                    }
                } catch (MqttPersistenceException e) {
                    Log.d(TAG,e.toString());
                } catch (MqttException e) {
                    Log.d(TAG,e.toString());
                }

            }
        });
    }
    @Override
    protected void onPause() {
        super.onPause();

        try {

            if(mqttAndroidClient.isConnected()) {
                mqttAndroidClient.disconnect();
                Log.d(TAG,"disconnect");
            }

            mqttAndroidClient.unregisterResources();

        } catch (MqttException e) {
            Log.d(TAG,e.toString());
        }

    }
}
【コード解説】
・33行目は、ブローカーのIPアドレスを設定します。(今回は、ラズパイのIPアドレスになります)
・92行目、110行目でトピック、メッセージをpublishしています。

■activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.inomacreate.kingyo_remote.MainActivity">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:text="きんちゃんエサやりモード"
        android:textSize="24sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/button_on"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ON"
        app:layout_constraintBaseline_toBaselineOf="@+id/button_off"
        app:layout_constraintStart_toStartOf="@+id/textView" />

    <Button
        android:id="@+id/button_off"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:layout_marginTop="19dp"
        android:text="OFF"
        app:layout_constraintStart_toEndOf="@+id/button_on"
        app:layout_constraintTop_toBottomOf="@+id/textView" />
</android.support.constraint.ConstraintLayout>

3. 動作確認

さて、早速、スマホ→ラズパイ→Arduino→サーボモーターの流れで、動作するかどうかを検証してみました。
まずは、ラズパイで以下のコマンドを実行し、作成したPythonプログラムを動かしておきます。
python arduino_serial_mqtt.py

次にスマホアプリを起動、ON/OFFボタンでサーボモーターを動かします。

やった!うまく動きました!!

4.ラズパイ起動時に自動でプログラムを起動する

【追記】2018年8月26日
水槽の横で動かすために、ラズパイ起動時に自動でプログラムを起動するようにします。
以下サイトによると、ラズパイ起動時にプログラムを動かす方法としては、5種類あるようですが、systemsとrc.localのやり方はうまくいかずハマりそうだったので、autorunというやり方を採用しました。

まずは、今回作ったプログラム「arduino_serial_mqtt.py」をシェルスクリプトで実行できるようにします。
テキストエディタで、以下のような「kingyo.sh」というスクリプトファイルを作成します。

/usr/bin/python /home/pi/arduino/serial_test/arduino_serial_mqtt.py

作成したシェルスクリプトに実行権限を与えます。
・chmod +x kingyo.sh

シェルスクリプト「kingyo.sh」を以下へコピーします。
・/usr/local/bin

次に、autostartのファイルを編集します。
テキストエディタで「autostart」を開きます。(以下はテキストエディタviを使用)
・vi /home/pi/.config/lxsession/LXDE-pi/autostart

最後の行に以下を追記します。
@kingyo.sh

これで、ラズパイ起動時に「kingyo.sh」を自動で実行してくれます。

最後に

スマホからリモート操作できるようになったので、あとは実際に金魚の水槽の横で動かしてみたいと思います。
そのためには、ラズパイを起動したときに作成したPythonプログラムを自動で動かすこと、あとはラズパイの電源OFFをどうするかなど。
もう少し課題がありそうです。また随時対応したら記事を更新します!
【更新情報】2018年8月26日 ラズパイ起動時に自動でプログラムを起動するようにしました。これで、水槽の横で動かせるようになりました。(詳細は、4章を参照ください)

それでは!

関連記事(一部広告含む)

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA


ページトップボタン