LoginSignup
5
7

More than 5 years have passed since last update.

Android Thingsで勤怠連絡ボタンで作ってみる

Posted at

概要

下記3つの記事のAndroid Things × Raspberry Pi3(以後ラズパイ)版

3つ目の記事と同様、ラズパイでボタンを押したら、勤怠連絡が飛ぶようにする

前提

検討フロー

下記のどちらかでやってみようと考え、

  1. ラズパイ -> AWS API Gateway -> AWS Lambda -> myThings Developers
  2. ラズパイ -> Google Cloud Pub/Sub -> Google Cloud Funtions -> myThings Developers
    • 天気サンプルのPub/Subを参考にする
    • ボタンを押したらPublishし、subscribeして受けた方はFunctionsを呼び出す
    • そのFuntionsの中でmyThings Developersを呼び出す

今回は2のGCPを使うパターンでやってみることにします。
ベースのソースコードはボタンでLチカサンプルを使い、必要に応じて、天気サンプルのPub/Sub部分を使う

Google Cloud Pub/Sub

github上の説明を参考にして、GCPの準備をする

  1. APIマネージャーでPub/Subを有効にする
  2. IAMと管理ページで、新しいサービスアカウントを作成(役割は特に入れなくて問題無し)、その時に新しい秘密鍵をjson形式で作成しておく(完了するとjsonファイルがダウンロードされる)
  3. 新しいトピックを作成して、2で作成したサービスアカウントに権限を付与、その際の役割は「Pub/Subパブリッシャー」にしておく
  4. 作成したトピック配下で、新しいサブスクリプションを作成
  5. 2で作成した際にダウンロードされたjsonファイルを「credentials.json」にリネームし、res配下にrawフォルダを作成してそこに置く
  6. app/build.gradleを下記のように修正
app/build.gradle
・・・・・
    buildTypes {
        debug {
            buildConfigField "String", "PROJECT_ID", '"<<プロジェクトID>>"'
            buildConfigField "String", "PUBSUB_TOPIC", '"<<topic>>"'
        }
        release {
            initWith(buildTypes.debug)

            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
・・・・・
dependencies {
    compile 'com.google.android.things.contrib:driver-button:0.2'
    provided 'com.google.android.things:androidthings:0.3-devpreview'

    compile('com.google.api-client:google-api-client-android:1.22.0') {
        exclude group: 'org.apache.httpcomponents'
    }
    compile('com.google.apis:google-api-services-pubsub:v1-rev12-1.22.0') {
        exclude group: 'org.apache.httpcomponents'
    }
}

Google Cloud Functions

発火タイミングは、上記で準備したCloud Pub/Subのトピックに対するメッセージの着信時とする。

  1. Cloud Functionsで関数を作成をクリック
  2. 各設定は下記のようにする
    スクリーンショット 2017-04-16 0.27.03.png

  3. 作成したコードは下記(myThingsとAmazon Dash ButtonをAWS経由で繋げてみるで使用したjsとほぼ一緒)

index.js
'use strict';

const qs = require("querystring");
var https = require("https");
var date = new Date();
var dateString = createPostTimeString();

// myThings Developersに必要なリクエスト項目
var appid = "<<appid>>";
var secret = "<<secret>>";
var accessToken = "<<accessToken>>";
var refreshToken = "<<refreshToken>>";

/**
 * Triggered from a message on a Cloud Pub/Sub topic.
 *
 * @param {!Object} event The Cloud Functions event.
 * @param {!Function} The callback function.
 */
exports.subscribe = function subscribe(event, callback) {
  // myThings Developersへリクエスト
  requestDevelopers(callback);
};

/**
 * "2016/12/21(水)"の形式の文字列を返す
 * @return string 日付文字列
 */
function createPostTimeString() {
    // 年
    var year = date.getFullYear();
    // 月
    var month = date.getMonth() + 1;
    if (month < 10) {
        month = '0' + month;
    }
    // 日
    var day = date.getDate();
    if (day < 10) {
        day = '0' + day;
    }
    // 曜日
    var weekDayList = [ "", "", "", "", "", "", "" ];
    var weekDay = weekDayList[ date.getDay() ];

    return year+"/"+month+"/"+day+"("+weekDay+")";
}

/**
 * myThings Developersへのリクエスト
 * @return void
 */
function requestDevelopers(callback) {
    // リクエストパラメータの生成
    var postArgs = {
        date: dateString
    }
    var postData = qs.stringify({
        "entry": JSON.stringify(postArgs),
    });

    // リクエスト設定
    var options = {
        hostname: "mythings-developers.yahooapis.jp",
        path: "/v2/services/<<hogehoge>>/mythings/<<hugahuga>>/run",
        port: 443,
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "Authorization": "Bearer " + accessToken,
        },
    };

    // リクエスト
    var req = https.request(options, function(res){
        // 401のとき
        if (res.statusCode == 401) {
            // コールバック付きのrefreshAccessTokenを呼ぶ
            refreshAccessToken(callback);
            return;
        }

        // レスポンス処理
        res.on("data", function(body){
            var parseData = JSON.parse(body);
            if(typeof( parseData["flag"] ) != "undefined") {
                callback();
            } else {
                console.log("カスタムトリガーの実行リクエストの受付に失敗しました。:"+body);
            }
        });
    })
    .on("error", function(res){
        callback();
    });
    req.end(postData)
}

/**
 * アクセストークンのリフレッシュ
 */
function refreshAccessToken(callback) {
    console.log("refreshAccessTokenにきたよ");
    // リフレッシュ用データのセット
    var reqData = qs.stringify({
        "grant_type": "refresh_token",
        "refresh_token": refreshToken
    });
    // リクエスト設定
    var buffer = new Buffer(appid + ":" + secret, "ascii");
    var options = {
        hostname: "auth.login.yahoo.co.jp",
        path: "/yconnect/v1/token",
        port: 443,
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            "Authorization": "Basic " + buffer.toString("base64"),
        }
    };

    // リクエスト実行
    var req = https.request(options, function(res) {
        console.log("refreshAccessTokenのrequestのなかにきたよ");
        // 401の場合
        if(res.statusCode == 401) {
            callback();
        } else if(res.statusCode != 200) {
            callback();
        }

        // レスポンス処理
        res.on('data', function(body){
            var parseData = JSON.parse(body);
            accessToken = parseData['access_token'];
            requestDevelopers(callback);
        }); 
    });

    // POSTデータのリクエスト
    req.end(reqData);
}

Android Things側の修正

  • AndroidManifest.xmlは、下記の部分を追記
AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  • Pub/Sub部分は天気サンプルの[PubsubPublisher.java]をベースにして、下記のように実装。
PubsubPublisher.java
class PubsubPublisher {
    private static final String TAG = PubsubPublisher.class.getSimpleName();

    private final Context mContext;
    private final String mAppname;
    private final String mTopic;

    private Pubsub mPubsub;
    private HttpTransport mHttpTransport;

    private Handler mHandler;
    private HandlerThread mHandlerThread;

    PubsubPublisher(Context context, String appname, String project, String topic,
                    int credentialResourceId) throws IOException {
        mContext = context;
        mAppname = appname;
        mTopic = "projects/" + project + "/topics/" + topic;

        mHandlerThread = new HandlerThread("pubsubPublisherThread");
        mHandlerThread.start();
        mHandler = new Handler(mHandlerThread.getLooper());

        InputStream jsonCredentials = mContext.getResources().openRawResource(credentialResourceId);
        final GoogleCredential credentials;
        try {
            credentials = GoogleCredential.fromStream(jsonCredentials).createScoped(
                    Collections.singleton(PubsubScopes.PUBSUB));
        } finally {
            try {
                jsonCredentials.close();
            } catch (IOException e) {
                Log.e(TAG, "Error closing input stream", e);
            }
        }
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                mHttpTransport = AndroidHttp.newCompatibleTransport();
                JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
                mPubsub = new Pubsub.Builder(mHttpTransport, jsonFactory, credentials)
                        .setApplicationName(mAppname).build();
            }
        });
    }

    public void start() {
        mHandler.post(mPublishRunnable);
    }

    public void close() {
        mHandler.removeCallbacks(mPublishRunnable);
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                try {
                    mHttpTransport.shutdown();
                } catch (IOException e) {
                    Log.d(TAG, "error destroying http transport");
                } finally {
                    mHttpTransport = null;
                    mPubsub = null;
                }
            }
        });
        mHandlerThread.quitSafely();
    }


    private Runnable mPublishRunnable = new Runnable() {
        @Override
        public void run() {
            ConnectivityManager connectivityManager =
                    (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
            NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo();
            if (activeNetwork == null || !activeNetwork.isConnectedOrConnecting()) {
                Log.e(TAG, "no active network");
                return;
            }

            try {
                JSONObject messagePayload = createMessagePayload();
                Log.d(TAG, "publishing message: " + messagePayload);
                PubsubMessage m = new PubsubMessage();
                m.setData(Base64.encodeToString(messagePayload.toString().getBytes(),
                        Base64.NO_WRAP));
                PublishRequest request = new PublishRequest();
                request.setMessages(Collections.singletonList(m));
                mPubsub.projects().topics().publish(mTopic, request).execute();
            } catch (JSONException | IOException e) {
                Log.e(TAG, "Error publishing message", e);
            } finally {
                // 天気情報のように定期的に情報をpublishしたいときは、下記のように1分おきにやるなどをする
                //mHandler.postDelayed(mPublishRunnable, PUBLISH_INTERVAL_MS);
            }
        }

        private JSONObject createMessagePayload()
                throws JSONException {
            JSONObject messagePayload = new JSONObject();
            messagePayload.put("deviceId", Build.DEVICE);
            messagePayload.put("channel", "pubsub");
            messagePayload.put("timestamp", System.currentTimeMillis());
            return messagePayload;
        }
    };
}
ButtonActivity.java
public class ButtonActivity extends Activity {
    private static final String TAG = ButtonActivity.class.getSimpleName();

    private Gpio mLedGpio;
    private ButtonInputDriver mButtonInputDriver;
    private PubsubPublisher mPubsubPublisher;
    private boolean mLock = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.i(TAG, "Starting ButtonActivity");

        PeripheralManagerService pioService = new PeripheralManagerService();
        try {
            Log.i(TAG, "Configuring GPIO pins");
            mLedGpio = pioService.openGpio(BoardDefaults.getGPIOForLED());
            mLedGpio.setDirection(Gpio.DIRECTION_OUT_INITIALLY_LOW);

            Log.i(TAG, "Registering button driver");
            // Initialize and register the InputDriver that will emit SPACE key events
            // on GPIO state changes.
            mButtonInputDriver = new ButtonInputDriver(
                    BoardDefaults.getGPIOForButton(),
                    Button.LogicState.PRESSED_WHEN_LOW,
                    KeyEvent.KEYCODE_SPACE);
            mButtonInputDriver.register();
        } catch (IOException e) {
            Log.e(TAG, "Error configuring GPIO pins", e);
        }

        // start Cloud PubSub Publisher if cloud credentials are present.
        int credentialId = getResources().getIdentifier("credentials", "raw", getPackageName());
        if (credentialId != 0) {
            try {
                mPubsubPublisher = new PubsubPublisher(this, "sample-button",
                        BuildConfig.PROJECT_ID, BuildConfig.PUBSUB_TOPIC, credentialId);
            } catch (IOException e) {
                Log.e(TAG, "error creating pubsub publisher", e);
            }
        }
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_SPACE) {
            // Turn on the LED
            setLedValue(true);
            sendrequest();
            return true;
        }

        return super.onKeyDown(keyCode, event);
    }

    @Override
    public boolean onKeyUp(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_SPACE) {
            // Turn off the LED
            setLedValue(false);
            mLock = false;
            return true;
        }

        return super.onKeyUp(keyCode, event);
    }

    /**
     * Update the value of the LED output.
     */
    private void setLedValue(boolean value) {
        try {
            mLedGpio.setValue(value);
        } catch (IOException e) {
            Log.e(TAG, "Error updating GPIO value", e);
        }
    }

    /**
     * リクエスト送信しちゃうぞ
     */
    private void sendrequest() {
        // onKeyDownの間、ずっと通信されない用の対応
        if (!mLock) {
            mLock = true;
                        Toast.makeText(this, "Press attendance contact button!!", Toast.LENGTH_LONG).show();![IMG_5206.GIF](https://qiita-image-store.s3.amazonaws.com/0/71637/74344646-e10d-8170-22a0-9adfeb48f586.gif)

            if (mPubsubPublisher != null) {
                mPubsubPublisher.start();
            }
        }
    }

    @Override
    protected void onDestroy(){
        super.onDestroy();

        if (mButtonInputDriver != null) {
            mButtonInputDriver.unregister();
            try {
                mButtonInputDriver.close();
            } catch (IOException e) {
                Log.e(TAG, "Error closing Button driver", e);
            } finally{
                mButtonInputDriver = null;
            }
        }

        if (mLedGpio != null) {
            try {
                mLedGpio.close();
            } catch (IOException e) {
                Log.e(TAG, "Error closing LED GPIO", e);
            } finally{
                mLedGpio = null;
            }
            mLedGpio = null;
        }

        if (mPubsubPublisher != null) {
            mPubsubPublisher.close();
            mPubsubPublisher = null;
        }
    }
}

実際に動かしてみた

IMG_5207.GIF

感想

  • Android Studioで開発して、そのままビルドできるので、Androidエンジニアの方だと取っ付きやすそう
  • Googleの提供ライブラリで簡単に開発ができる
  • GCP(Pub/Sub、Dataflow、Funtionsなど)との親和性があるのでクラウド連携はとてもしやすい

後々やりたいこと

  • MQTTを使った通信
  • GCPのSpannerにデータを保存
  • Dataflowにデータを流してみる
  • Firebaseとの連携
  • SORACOMを使っての接続

参考

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7