空気品質モニターアプリをラズパイにdoker-composeで実装してみた(Docker + Typescript + Nuxt.js + Python + MongoDB + Nginx)

# はじめに
![frontend.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/dd623207-3d44-0f30-c396-78a0b385aba7.png)
キレイな空気が吸いたい！あわよくば、仕事も効率UPして清々しい1日を送りたい！！そう思い立ちまして、早速ラズベリーパイを使って自宅の空気品質モニターアプリを作ってみました:lifter_tone3:

[GitHubリポジトリはコチラ](https://github.com/akiraseto/airwatch)


# 特徴
こんなことができます↓

- リビングの空気品質をモニター
- 書斎の空気品質をモニター
- 10分毎にセンサー値を取得し続けてDB保存
- ラズパイを再起動してもアプリも自動起動
- 描画高速化のため限定数のデータでグラフ表示。非同期で残データ取得しシームレスにグラフを再描画
- 10分ごとにデータを再取得し、グラフをシームレスに更新し続ける。
- 1時間ごとのグラフもタブで表示
- 固定IP,VPN利用で、外出先からでもWebアプリ利用化(API通信におけるバックエンドのIPアドレスを簡易解決)

ざっくりと上記の機能です。
他に、
URLクエリを追加することで、初期のグラフ描画データ数を任意に設定できるようにしました。
`http://xxx?limit=10` =>10個のデータでグラフ描画。外出先でスマホからだと通信早くなり画面も小さいので重宝します。


# 全体構成
ラズパイ上で、docker-composeを利用してコンテナ実装します。
![system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/24a8f31c-48ca-5560-14ad-69bfaa0ec521.png)

- センサー取得コンテナ:Pythonでセンサー値を取得しDBにインサート
- DBコンテナ:MongoDBコンテナ(32bit)
- バックエンドコンテナ:Python FlaskでAPIサーバー
- フロントエンドコンテナ:NginxでWebサーバーコンテナ
- Nuxt.js:Nginxで使う静的コンテンツをNux.jsで生成


# 必要なもの
## ラズパイ

今回の実行環境です。
device:Raspberry Pi 3 Model B
OS: Raspbian GNU/Linux 9 (stretch)

自宅ローカルネットワークで固定IP化しておきます。
また、外出先からも利用したければTailscaleなどインストールしてVPNでアクセスできるようにしておくと便利です。


## GPIOセンサー
書斎の空気品質チェックに使用したセンサーです。
![gpio.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/b343adee-6e16-5998-5ff7-f6b9572bc0a5.jpeg)
以下のセンサーをGPIO接続して値を取得しました。

- BMP180:温度、気圧、高度、海面気圧
- DHT11:温度、湿度
- SGP30:CO2(eco2), TVOC, エタノール, H2

実の所、エタノールやH2値を見てもピンときませんでした。。センサーは固定設置なので高度も、海面気圧もいらないです。不要な値はグラフ表示から外しました。

GPIOの接続は次の通り。
BMP180, SGP30:i2cで並列接続
DHT11: PIN4番使用


## ダイキン空気清浄機
リビングに設置したダイキン空気清浄機を利用して空気品質をモニターしました。
気温、湿度、匂い、PM25、ホコリの値が取れます。
![daikin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/b57784e4-7ad8-e88d-ef3f-04040e633e23.png)
この機種でなくても、ローカルネットワークでAPI通信できるIOT家電なら代用可能です。
詳しくは過去の記事をご覧ください↓
[IOT家電をAPIハックして、Siriから簡単に操作する方法](https://qiita.com/akinko/items/aaa269968c74fb6041e7)


# センサーコンテナ
10分ごとの周期で各センサーから値を取得して、DBにインサートします。
公式のPythonコンテナを利用しています。

以下は主なコードです↓

```python:sensors/app.py
#***省略***
if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)

    sensors = [
        Daikin(),
        Bmp(),
        Dht(),
        Sgp()
    ]

    delta_list = {'minute': datetime.timedelta(minutes=1),
                  'hour': datetime.timedelta(hours=1),
                  'day': datetime.timedelta(days=1),
                  'week': datetime.timedelta(weeks=1)}

    # 最新DBデータ取得
    latest_data = defaultdict(dict)
    for period in delta_list:
        for sensor in sensors:
            name = sensor.__str__()
            latest_data[name][period] = sensor.find_latest(period)

    minutes_time = int(os.environ['BASIC_MINUTE'])
    while True:
        now = datetime.datetime.now()
        data_sensors = {}
        for sensor in sensors:
            name = sensor.__str__()
            data = sensor.get_sensor()
            data_sensors[name] = data
            logging.info('{}:{}'.format(name, data))

        for period, delta in delta_list.items():
            for sensor in sensors:
                name = sensor.__str__()
                if latest_data[name][period] is None or \
                        now >= latest_data[name][period]['timestamp'] \
                        + delta:
                    if data_sensors[name] is not None:
                        sensor.insert_data(period, data_sensors[name])
                        latest_data[name][period] = data_sensors[name]

        time.sleep(minutes_time * 60)


```

- ループ処理で回すための初期設定を作る。
    - `sensors`:取得したいセンサーのインスタンスをリスト化。  
    今後センサーを追加する事があれば、modelと、このリストに追記だけで対応できる。
    - `delta_list`:センサー取得タイミング。これにより取得タイミングを制御  
    key名でMongoDBの各センサーのデータベースにコレクションとして自動生成される。
- センサー値取得ループは各センサーオブジェクトをポリモーフィズムを考慮して作成。  
  (同一名、同一機能メソッドに揃えている)
    - `sensor.__str__()`:各センサー名を取得してデータを格納
    - `sensor.find_latest()`:各センサーオブジェクトに共通のメソッドを呼び出す。


# バックエンドコンテナ
FlaskでAPIサーバーを実装しました。
今回は、普段より意識して各センサーの共通コードを親クラスになるべくまとめましたので、コードがスッキリして見通しがよくなり気持ちがいいですね:sunny:

コンテナに関しては、RasbianOSイメージを利用しています。
理由は1点。Pandasが入らなかったからです。。:skull:
センサーコンテナと同様にPython公式イメージを利用しようとしたのですが、色々試しても32bitのラズパイにPandasが入らない。。入ってもエラーだらけで全く動きませんでした。
そんな訳で、ラズパイと合わせた32bit対応のRasbianOSイメージを利用してPandasをインスール、実装しています。
ちなみに、`apt-get install python3-panda`で入れるPandasはけっこう古くて、仮引数名やメソッドなど新しいバージョンといくつか異なります。直すのちょっと面倒でした。。

特筆したいコードは以下です↓

```py:backend/db.py
#***省略***
class MongoDB:
    #***省略***

    def get_data(self, params):
        #***省略***

        db_col = self.db.get_collection(params['period'])

        item_list = [self.change_datetime(item) for item in db_col.find(
            sort=[('timestamp', -1)],
            projection={'_id': 0, },
            filter={'timestamp': {'$gte': params['from'], '$lt': params['to']}}
        ).limit(params['limit'])]

        item_list.reverse()

        df = pd.DataFrame(item_list)
        result = df.to_dict('list')

        return result

    @staticmethod
    def change_datetime(data):
        """datetime(UTC)をJSTの文字列に変換."""
        t_str = "%Y-%m-%d %H:%M:%S"
        data['timestamp'] = (data['timestamp'] + timedelta(hours=9)).strftime(
            t_str)

        return data
```

- `item_list = [self....`:DBから取得しつつ、リスト内包表記で`change_datetime()`メソッドで日本時間に変更した日付文字列に変更したデータリストを取得しています。
- `df.to_dict('list')`:フロントのグラフ表記しやすいデータ形式にPandasを使って変更しています。


# DBコンテナ
ラズパイが32bitのため、MongoDBのオフィシャルイメージが使えません。
今回は32bitで、かつ`pymongo`が問題なく動作するイメージを利用しています。

プライベートでの限定使用のため、ユーザー認証を省きました。
コンテナ起動時に`mongod.lock`を削除して起動することで、簡易的に認証無しでmongoを立ち上げています。


# フロントエンドコンテナ
Nginxコンテナで静的コンテンツ読み込み、サクッとWebサーバーコンテナ化しました。


# Nuxt.js

## BuildでSSR それとも Generateでstatic contents?
最初はフロントコンテナでSSRする予定だったのですが、Build時に`Javascript heap out of memory`により失敗。
ラズパイのSwapメモリーをNode.jsのデフォルト使用メモリに合わせて2GBまで引き上げたり、`ncu`でモジュールを最新に変更したり、nodeイメージのバージョン変えたり、`npm`のバージョン変えたり..etc... しかし、同じエラーでBuild失敗。もちろん同じコードでMacでは問題なくBuildできます。

おそらくラズパイの32bit環境と何らかのモジュールが不具合を起こしてるのではと推測。
ログ見て調べようとしましたが、思いとどまり、
そもそも、今回のアプリは静的コンテンツで良くないか？SSRにする必要あるのか？
ラズパイはメモリ小さいのに、無駄にSSRしてNodeのプロセスを走らせる必要はないのでは？・・
そんなわけで、静的コンテンツを生成してNginxでサクッとWebサーバーコンテナ化に変更しました。

また、外出先でもTailSaleを通して自宅空気をモニターしたかったので、`location.href`によって、ラズパイのローカルIP以外でのリクエストでもバックエンドにAPI通信できるようにしています:globe_with_meridians:

役割とファイル構成は以下になります↓

```
.
├── components
│   ├── Daikin.vue     # ダイキンセンサーのグラフ描画
│   ├── Gpio.vue       # GPIO接続センサーのグラフ描画
│   └── Header.vue     # ヘッダー
├── layouts
│   └── default.vue    # レイアウト
├── pages
│   └── index.vue      # メインページ
└── plugins
    ├── hourAPI.ts     # 1時間毎のデータ取得処理
    ├── initialAPI.ts  # 初期のデータ取得処理
    └── repeatedAPI.ts # 10分毎にデータ再取得の処理
```

- 描画したいブロックごとに、componetsに分割
- 処理させたいロジックごとに、pluginsに分割

分割意識すると、ファイル構成がスッキリして、追加があっても対応しやすいです。

- IOT家電が増えるなら、componetsに新たにファイル追加する。
- GPIOセンサーが増えるなら、Gpio.vueの`data()`と、htmlの表記部分だけを書き加える。

それだけで、メソッド変更などロジックをいじることなく、追加機器対応できます。うん！楽だ:musical_note:
そうなると、、なんか色々とセンサー盛り込みたくなってきますww


# おわりに
楽しかったですが、今回は面倒くさかったが一番の感想です。
コーディングも、ラズパイもセンサーも楽しいのですが、とにかく

**ラズパイが32bitなので、多くの Dockerイメージと不具合が出る**
これの回避や、プログラムのリライトが辛かった。。:skull:

これって、どの環境でも動くっていうコンテナ技術の最大のメリットが享受できず、逆に書き直し必要というデメリットになっちゃってます、本末転倒ですよね。。.

Docker使う際は、次回からJetsonNanoにしようと思います。

- 64bit
- OSはubuntu
- Dockerが標準サポート
- Python最適化
- GPIOも搭載
- メモリーも4GB

Docker利用予定なら、Jetsonの方がすんなり入ってストレスなく実装できそうです。

とはいえ、大好きなので引き続き使うよ。ラズパイ！
今回は辛みもありましたが、得ることの多い実装でした:hand_splayed_tone3: