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

こんにちは、koheiです。
以前、Arduinoを使って金魚の自動餌やり機を作成しましたが、今回は、ラズパイ3を組み合わせて、スマホ(Android)からリモートで操作できるようにしました。
Aruduino単体では、Wifiモジュールがついていないため、無線通信ができません。ラズパイ3はWifiがついているので、無線通信できます。多分ラズパイだけでも金魚の餌やり機が作れると思いますが、せっかくArduinoで餌やり機を作っていたので、今回はラズパイ+Arduinoでリモート操作を実現してみました。
備忘録としてまとめておきます。
目次
用意したもの
以前の金魚餌やり機から追加したものは、ラズパイ3B+、Android端末です。
・ラズパイ3B+
・Androidスマホ
回路図
回路図は前回の金魚餌やり機の回路にUSBケーブルでArduinoとラズパイをつなぐだけです。(Arduino側の回路図は前回の記事を参照ください)

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

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

1.Arduinoとラズパイ間のシリアル通信を構築
まずは、Arduinoとラズパイ間のシリアル通信を実装していきます。
シリアル通信の仕様は、シンプルに[on]or[off]を通知してモーターを動かす、止めるという仕様にしました。
・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, ×tamp, 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というライブラリを使って実装します。
以下の記事を参考にさせていただきました。
MQTT クライアント を Android に実装する
まずは、ライブラリファイルを以下のサイトからダウンロードし、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章を参照ください)
それでは!

