Firebase + Nuxt.js + RaspberryPiで作る猫監視システム

かねてから作りたかった動体検知と画像判定を用いた猫監視アプリを作成することができたので、ご紹介です。

## 完成したもの
FirebaseにホスティングされたPWA対応のWebアプリです。

ラズパイのカメラが猫を検知すると、画像がアップロードされ、飼い主にPush通知が飛びます。

<figure style="display: inline-block;">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/628e0dcd-14c0-c58c-cfc8-21402cec5e65.jpeg" width="30%" style="background-color: rgba(255, 255, 255, 0); border: none; box-shadow: none; margin: 20px;" />
</figure>
<figure style="display: inline-block;">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/4969b69d-7526-4596-6c0a-3898b7a63f82.jpeg" width="30%" style="background-color: rgba(255, 255, 255, 0); border: none; box-shadow: none; margin: 20px;" />
</figure>
<figure style="display: inline-block;">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/9b6ad60d-a2c2-aabd-91e0-4b03c7d2704e.jpeg" width="30%" style="background-color: rgba(255, 255, 255, 0); border: none; box-shadow: none; margin: 20px;" />
</figure>

![P_20190704_073840.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/b6040562-2381-a611-ebc0-f4a66696c3c1.jpeg)

##ソースコード
ソースコードは下記のリポジトリで公開しています。

<a href="https://github.com/reireias/cat-watcher"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/reireias/cat-watcher.png" width="460px"></a>
<a href="https://github.com/reireias/raspberrypi-ansible"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/reireias/raspberrypi-ansible.png" width="460px"></a>

## 機能一覧
- 動体検知による画像取得
- 画像判定
- Google認証
- 画像一覧
- PWA
- Push通知

## モチベーション
以下のモチベーションから、本システムを開発しました。

- 家に不在の時、飼い猫が何をしているのか気になった
- WebアプリでFirebaseを使い倒してみたかった

## 開発工数
ざっと3人日ほどです。
会社の開発合宿を利用して開発しました。
開発合宿ってどんなものか気になる方はこちら。
<a href="https://tech.medpeer.co.jp/entry/2019/07/04/170000" target="_blank"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/66d8580b-bccd-4aef-ab17-3cb81ca39a9b.png" /></a>


## 採用技術
本システムで採用した技術は下記の通りです。
![cat-tech-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/19ad04e1-bca6-1694-cb06-620c747db022.png)

### infra(Firebase)
- Cloud Firestore
  - リアルタイムNoSQL。ユーザー情報、画像情報の保存に利用。
- Cloud Functions
  - イベント駆動関数。Storageへの画像登録やFirestoreへのデータ登録をフックして起動。
- Cloud Storage
  - ストレージ。画像を保存するのに利用。
- Firebase Authentication
  - 認証。今回はGoogle認証のみとした、
- Firebase Cloud Messaging
  - Push通知機能。トピック購読者に対する配信を利用。
- Firebase Hosting
  - 静的サイトホスティング機能。SPAとしてフロントを構築し、ここにデプロイしている。

### client
- Vue.js
  - 言わずと知れたjsフレームワーク。
- Vuetify.js
  - Vue.js用のマテリアルUIライブラリ。最近は必ず使ってる。
- Nuxt.js
  - PWA、Flux、SPAなどを簡単に実現できるVue.js製フレームワーク。

### hardware
- RaspberryPi ZERO WH + カメラモジュール
  - モバイルバッテリーによる運用を意識して低消費電力のZeroモデルにした。


## システム構成
システム構成全体は下記のようになっています。
細かい処理フローや実装はおって説明していきます。
![cat-system-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/73b97ac6-0d35-9367-752b-6d56e70df92a.png)


## 処理フローと実装
### ユーザー認証時
![cat-system-auth-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/a22d2aa8-ef44-4ae7-36ff-4532469abfa2.png)
ユーザーがサイトにアクセスし、Google認証連携によりサインインしたときに、データをFirestoreに保存します。後述の通知用のトークンを保存するためにユーザーデータをFirestoreに保持しています。
`functions.auth.user().onCreate()`でユーザーの初回認証時に動作する関数をFunctionsで定義します。

```js:functions/index.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')

admin.initializeApp()

exports.createUserData = functions.auth.user().onCreate(user => {
  const data = {
    name: user.email.split('@')[0],
    displayName: user.displayName,
    email: user.email,
    photoURL: user.photoURL,
    uid: user.uid,
    createdAt: admin.firestore.FieldValue.serverTimestamp()
  }
  const db = admin.firestore()
  const ref = db.collection('users').doc(user.uid)
  ref.set(data)
  return 0
})
```

### 通知許可時
![cat-system-subscribe-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/c615dd24-8068-30c9-0e4d-e5c323dc743f.png)

サイトの右上に通知をONにするボタンを設置しています。
ユーザーがブラウザやPWAで通知を許可した場合、発行されるトークンをFirestore上のユーザーデータの属性に追加します。
追加後、下記のFunctionsがフックされ、通知用のトピックを購読させます。

Functions上でsubscribeを実施しているのはセキュリティ上の都合です。
詳細は[公式ドキュメント](https://firebase.google.com/docs/cloud-messaging/manage-topics?hl=ja)を参照してください。

```js:functions/index.js
exports.subscribeTopic = functions.firestore
  .document('/users/{userId}')
  .onUpdate(async (change, context) => {
    const token = change.after.data().messagingToken
    console.log(token)
    const res = await admin.messaging().subscribeToTopic(token, '/topics/cat')
    console.log(res)
    return null
  })
```

### 動体検知〜画像アップロード
![cat-system-upload-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/387cbc24-318d-fda5-e167-4392fabbaf6c.png)


ラズパイ上で稼働している`motion`コマンドがカメラモジュールを経由して、動体を検知します。
動体が検知されると、その画像を生成するように`motion`コマンドを設定しています。

`motion`コマンドとは別に、画像の生成を検知し、画像をアップロードする下記のスクリプトを稼働させています。
`inotifywatch`コマンドで`motion`が生成する画像を置くディレクトリを監視しています。
画像ファイルが生成されたら`gsutil`コマンドを使ってCloud Storageへ画像をアップロードする実装になっています。

```bash:roles/cat/files/upload.sh
#!/bin/bash

TARGET_DIR="/tmp/motion"

mkdir -p ${TARGET_DIR}
while inotifywait -e CREATE ${TARGET_DIR}; do
    file=$(ls -rt ${TARGET_DIR} | tail -n 1)
    gsutil -o 'Credentials:gs_service_key_file=/root/.credentials.json' cp "${TARGET_DIR}/${file}" gs://cat-watcher.appspot.com/
done
```

### 猫判定〜画像メタデータ保存
![cat-system-label-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/4f8d5fdf-5779-e288-523b-e7cfce4678d8.png)


画像がStorageにアップロードされると、Functionsの`createImageData`関数が起動します。

`createImageData`関数ではGoogle Cloud Vision APIを呼び出し、画像にラベル付けを行います。
ラベル付けの結果、`Cat`ラベルが含まれていない場合は、画像を削除し、処理は終了します。
`Cat`ラベルが含まれている場合は、画像の公開URLを発行し、Firestoreの画像一覧データにメタ情報を追加します。
Firestoreにメタ情報を保存しているのは、UIでリアルタイム同期で画像一覧を表示する際に利用するためです。

```js:functions/index.js
exports.createImageData = functions.storage
  .object()
  .onFinalize(async object => {
    // 画像ファイル以外は何もしない
    if (!object.contentType.startsWith('image/')) {
      console.error('This is not an image.')
      return null
    }
    // 更新時は何もしない
    if (object.metageneration !== '1') {
      console.info('updated.')
      return null
    }
    // Vision APIを利用してラベル判定
    const client = new vision.ImageAnnotatorClient()
    const [result] = await client.labelDetection(
      `gs://${object.bucket}/${object.name}`
    )
    console.log(result.labelAnnotations)
    const cat = result.labelAnnotations.filter(
      annotation => annotation.description === 'Cat'
    )
    const file = admin
      .storage()
      .bucket(object.bucket)
      .file(object.name)
    if (cat.length > 0) {
      // Catラベルが付いていれば、公開URLを作成しFirestoreにメタデータ登録
      const [downloadUrl] = await file.getSignedUrl({
        action: 'read',
        expires: '01-01-2050'
      })
      const ref = admin
        .firestore()
        .collection('images')
        .doc()
      const data = {
        id: ref.id,
        name: object.name,
        url: downloadUrl,
        createdAt: admin.firestore.FieldValue.serverTimestamp()
      }
      await ref.set(data)
    } else {
      // Catラベルが付いていなければ、画像を削除する
      await file.delete()
      console.log('deleted')
    }

    return null
  })
```

### Push通知
![cat-system-messaging-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/dc3ff149-7c26-4231-a0a7-cf8a343c2d95.png)

上記のラベル判定用のFunctions関数中で画像一覧データにデータが追加されと、Functionsの`sendMessage`関数がフックされます。
`sendMessage`関数では、Cloud Messagingのトピックへ画像データが追加された旨を配信します。
この処理により、トピックを購読している＝通知を許可したユーザーの端末へPush通知が送信されます。

```js:functions/index.js
exports.sendMessage = functions.firestore
  .document('/images/{imageId}')
  .onCreate(async (snap, context) => {
    const message = {
      notification: {
        title: NOTIFICATION_TITLE,
        body: '新しい画像が追加されました',
        icon: 'https://cat-watcher.firebaseapp.com/android-chrome-512x512.png',
        click_action: 'https://cat-watcher.firebaseapp.com/'
      }
    }
    await admin.messaging().sendToTopic('/topics/cat', message)
    return null
  })
```

### クライアントでの表示
![cat-system-browse-gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/5561ccb7-ff2e-737e-a87f-b135a363e381.png)

自宅で運用するため、家族のみアクセスできるようにFirestoreとStorageのルールで制御しています。
サイトにアクセスされたら、[vuexfire](https://github.com/vuejs/vuefire/tree/master/packages/vuexfire)を使いFirestore上の画像メタ情報をvuexのstoreにロードしています。
画像メタ情報中の公開URLで画像をUIに表示しています。
また、認証用のUIは[firebaseui-web](https://github.com/firebase/firebaseui-web)で簡単に作成することができます。

## まとめ
FirebaseとRaspberryPiを使ってさくっと猫監視システムを作ってみました。
やっぱりうちの猫はかわいいなあ。