家の中のセンサデータをRaspberryPiで取得しまくり、スーパーIoTハウスを実現

# はじめに
巷ではスーパシティ法によるデータ管理が話題ですが、
インドア派な私はシティの前にハウスで時代の波に乗ろう！と思い立ち、
**大量のセンサデータをリアルタイムでダッシュボード表示**する仕組みを作りました。

**結論から言うと、センサデータを安定して見える化できるシステムが構築できた**と感じています。

![DashBoard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/ba1dc893-9375-15bb-442c-708441b386ea.png)

初心者の方でもわかりやすいよう、説明の飛躍のない記事作成を心がけたいと思います。
**飛躍、間違い等あれば、ご指摘頂けるとありがたいです！**

## 2021/12追記
さらに進化？したので以下の記事もご参照ください

https://qiita.com/c60evaporator/items/7d3785b16319e39297a4

# 必要なもの
**・RaspberryPi**（今回はPi3Model Bを使用）
**・Python実行環境**（今回はプリセットのPython3.7.3使用）
※RaspberryPiでのPython開発環境は試行錯誤の結果、[こちら](https://qiita.com/c60evaporator/items/26ab9cfb9cd36facc8fd)に落ち着きました
**・Googleアカウント**（スプレッドシートを使うのに必要）
**・スマホ**（BLE Scanner設定用）
**・各種センサ → 6種類のセンサを使用。詳細は下記**
※6種類を全て揃える必要はないので、購入したセンサに合わせ適宜実装してください

### 使用したセンサ一覧
・[Omron 環境センサBAG型（2JCIE-BL01）](https://www.fa.omron.co.jp/products/family/3723/)
・[Omron 環境センサUSB型（2JCIE-BU01）](https://www.fa.omron.co.jp/products/family/3724/)
・[Inkbird IBS-TH1 mini](https://www.amazon.co.jp/Inkbird-%E3%83%96%E3%83%AB%E3%83%BC%E3%83%88%E3%82%A5%E3%83%BC%E3%82%B9-%E6%B8%A9%E6%B9%BF%E5%BA%A6%E3%82%BB%E3%83%B3%E3%82%B5%E3%83%BC-Android-IBS-TH1/dp/B07D74CQLJ)
・[Inkbird IBS-TH1](https://www.amazon.co.jp/Inkbird-IBS-TH1-Bluetooth-%E5%A4%96%E9%83%A8%E3%83%97%E3%83%AD%E3%83%BC%E3%83%96%E4%BB%98%E3%81%8D-%E3%82%B9%E3%83%9E%E3%83%9B%E3%81%A8%E9%80%A3%E6%8E%A5%E3%81%A7%E3%81%8D/dp/B07D75H734/ref=pd_aw_sbs_201_3/356-7389076-5979439?_encoding=UTF8&pd_rd_i=B07D75H734&pd_rd_r=184465e6-f0d9-4e16-8524-c984893af9bd&pd_rd_w=Ip0MD&pd_rd_wg=lcDuD&pf_rd_p=bff3a3a6-0f6e-4187-bd60-25e75d4c1c8f&pf_rd_r=939W116VQ9B4YV5DQ69A&psc=1&refRID=939W116VQ9B4YV5DQ69A)
・[SwitchBot温湿度計](https://www.switchbot.jp/meter)
・[Nature Remo](https://nature.global/jp/nature-remo)

# システム構成
## 全体構成図
下図のような全体構成になっています。矢印はデータの流れを表しています。
![センサ値取得システム.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/0c7d2bdf-1a32-3a84-c0d3-8d41ead2a61c.png)

## 特徴
### 定期実行
手動スクリプト実行では長期運用が困難なので、自動定期実行の仕組みを作りました。
全体を統括するスクリプトsensors_to_spreadsheet.pyを、Raspberry Piのcronで定期実行してデータを蓄積しています。

### センサとRaspberryPiの無線通信
基本的には、Bluetooth Low Energy (BLE)で通信しています。
BLEは家庭向けIoT機器のデファクトスタンダードとなりつつある規格で、注意点は[こちらの記事](https://qiita.com/hose/items/86a539b732df59a98376)にわかりやすくまとめられています。
[詳細は下](https://qiita.com/c60evaporator/items/283d0569eba58830f86e#%E5%90%84%E7%A8%AE%E3%82%A8%E3%83%A9%E3%83%BC%E5%AF%BE%E5%BF%9C)で書きますが、BLEの安定化が今回の開発の最大の壁となりました。
なお、Nature RemoのみBLEではなくWiFiでの接続となっています。

### データ保存先
データ保存先を、ローカルでのCSV保存とGoogle Spreadsheetsの2系統に分けています。
CSV：あとで分析に使用
Google Spreadsheets：データポータルでの可視化に使用（クラウド上で可視化したかった）
という用途を想定して分けましたが、冗長だとも思うので、1系統に統一しても良いかと思います。
統一する場合、クラウド上でアクセスできるDBに保存できるのが理想だと思います。

# 手順
上記システムを、下記の手順で構築しました
**①センサの初期設定と動作確認**
**②センサデータ取得クラスを作成**
**③メインスクリプト作成**
**④PythonからGASのAPIを叩いてスプレッドシートにデータ書き込み**
**⑤スクリプトの定期実行**
**⑥Google Data Portalによる可視化**

詳細は次章で解説します

# 詳細
## ①センサの初期設定と動作確認
個別に記事を作成したので、センサごとに初期設定と動作確認を実施してください

・[Omron 環境センサBAG型（2JCIE-BL01）](https://qiita.com/c60evaporator/items/ed2ffde4c87001111c12)
・[Omron 環境センサUSB型（2JCIE-BU01）](https://qiita.com/c60evaporator/items/ced01c241031c6c9c379)
・[Inkbird IBS-TH1 mini](https://qiita.com/c60evaporator/items/e7c3b2c65ad1664c832f)
・[Inkbird IBS-TH1](https://qiita.com/c60evaporator/items/fcc16ac9ab582a25c376)
・[SwitchBot温湿度計](https://qiita.com/c60evaporator/items/7c3156a6bbb7c6c59052)
・[Nature Remo](https://qiita.com/c60evaporator/items/62b5ce11140088eabe6b)

## ②センサデータ取得クラスを作成
センサ種類ごとにセンサデータを取得するPythonクラスを作成します。
（Inkbird IBS-TH1 とIBS-TH1 miniは同一クラスで取得します」）

また、モジュール（.pyファイル）はメーカーごとにまとめました。
下記では、モジュールごと（メーカーごと）に節を分けて解説します

### Omron環境センサ
Omron 2JCIE-BL01＆2JCIE-BU01のデータ取得クラスを作成します。
内容は概ね
[BAG型](https://qiita.com/c60evaporator/items/ed2ffde4c87001111c12#%E3%82%BB%E3%83%B3%E3%82%B5%E6%B8%AC%E5%AE%9A%E5%80%A4%E5%8F%96%E5%BE%97%E7%94%A8%E3%82%AF%E3%83%A9%E3%82%B9%E3%82%92%E4%BD%9C%E6%88%90)
[USB型](https://qiita.com/c60evaporator/items/ced01c241031c6c9c379#%E3%82%BB%E3%83%B3%E3%82%B5%E5%80%A4%E5%8F%96%E5%BE%97%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)
を合わせたものです

**・外部からの呼び出し法**
**BAG型**：
OmronBroadcastScanDelegateクラスのインスタンスを作成し、

```python
インスタンス名.scan(timeout)
```
でセンサデータを取得できます。
引数「timeout」は、データ取得のタイムアウト値を秒単位で指定してください（ざっくりですが、3～5秒くらいが良さそうです）

**USB型**：
下記メソッドでセンサデータを取得できます

```python
GetOmronConnectModeData().get_env_usb_data(macaddr)
```
引数「macaddr」は、センサのMacアドレスを指定してください

**・実際のスクリプト**

```python:omron_env.py
# coding: UTF-8
from bluepy import btle
import struct

#Broadcastデータ（BAG型）取得用デリゲート
class OmronBroadcastScanDelegate(btle.DefaultDelegate):
    #コンストラクタ
    def __init__(self):
        btle.DefaultDelegate.__init__(self)
        #センサデータ保持用変数
        self.sensorValue = None

    # スキャンハンドラー
    def handleDiscovery(self, dev, isNewDev, isNewData):  
        # 新しいデバイスが見つかったら
        if isNewDev or isNewData:  
            # アドバタイズデータを取り出し
            for (adtype, desc, value) in dev.getScanData():  
                #環境センサのとき、データ取り出しを実行
                if desc == 'Manufacturer' and value[0:4] == 'd502':
                    #センサの種類（EP or IM）を取り出し
                    sensorType = dev.scanData[dev.SHORT_LOCAL_NAME].decode(encoding='utf-8')
                    #EPのときのセンサデータ取り出し
                    if sensorType == 'EP':
                        self._decodeSensorData_EP(value)
                    #IMのときのセンサデータ取り出し
                    if sensorType == 'IM':
                        self._decodeSensorData_IM(value)

    # センサデータを取り出してdict形式に変換（EPモード時）
    def _decodeSensorData_EP(self, valueStr):
        #文字列からセンサデータ(6文字目以降)のみ取り出し、バイナリに変換
        valueBinary = bytes.fromhex(valueStr[6:])
        #バイナリ形式のセンサデータを整数型Tapleに変換
        (temp, humid, light, uv, press, noise, discomf, wbgt, rfu, batt) = struct.unpack('<hhhhhhhhhB', valueBinary)
        #単位変換した上でdict型に格納
        self.sensorValue = {
            'SensorType': 'Omron_BAG_EP',
            'Temperature': temp / 100,
            'Humidity': humid / 100,
            'Light': light,
            'UV': uv / 100,
            'Pressure': press / 10,
            'Noise': noise / 100,
            'Discomfort': discomf / 100,
            'WBGT': wbgt / 100,
            'BatteryVoltage': (batt + 100) / 100
        }
    
    # センサデータを取り出してdict形式に変換（IMモード時）
    def _decodeSensorData_IM(self, valueStr):
        #文字列からセンサデータ(6文字目以降)のみ取り出し、バイナリに変換
        valueBinary = bytes.fromhex(valueStr[6:])
        #バイナリ形式のセンサデータを整数型Tapleに変換
        (temp, humid, light, uv, press, noise, accelX, accelY, accelZ, batt) = struct.unpack('<hhhhhhhhhB', valueBinary)
        #単位変換した上でdict型に格納
        self.sensorValue = {
            'SensorType': 'Omron_BAG_IM',
            'Temperature': temp / 100,
            'Humidity': humid / 100,
            'Light': light,
            'UV': uv / 100,
            'Pressure': press / 10,
            'Noise': noise / 100,
            'AccelerationX': accelX / 10,
            'AccelerationY': accelY / 10,
            'AccelerationZ': accelZ / 10,
            'BatteryVoltage': (batt + 100) / 100
        }

#Connectモード（USB型）データ取得クラス
class GetOmronConnectModeData():
    def get_env_usb_data(self, macaddr):
        peripheral = btle.Peripheral(macaddr, addrType=btle.ADDR_TYPE_RANDOM)
        characteristic = peripheral.readCharacteristic(0x0059)
        return self._decodeSensorData_EP(characteristic)

    def _decodeSensorData_EP(self, valueBinary):
        (seq, temp, humid, light, press, noise, eTVOC, eCO2) = struct.unpack('<Bhhhlhhh', valueBinary)
        sensorValue = {
                'SensorType': 'Omron_USB_EP',
                'Temperature': temp / 100,
                'Humidity': humid / 100,
                'Light': light,
                'Pressure': press / 1000,
                'Noise': noise / 100,
                'eTVOC': eTVOC,
                'eCO2': eCO2
            }
        return sensorValue
```

### Inkbird IBS-TH1 & IBS-TH1 mini
Inkbird IBS-TH1 & IBS-TH1 miniのデータ取得クラスを作成します。
内容は概ね
[IBS-TH1](https://qiita.com/c60evaporator/items/fcc16ac9ab582a25c376#%E3%82%BB%E3%83%B3%E3%82%B5%E5%80%A4%E5%8F%96%E5%BE%97%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)
[IBS-TH1 mini](https://qiita.com/c60evaporator/items/e7c3b2c65ad1664c832f#%E3%82%BB%E3%83%B3%E3%82%B5%E5%80%A4%E5%8F%96%E5%BE%97%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)
を合わせたものです

**・外部からの呼び出し法**
Inkbird_IBSTH1、Inkbird_IBSTH1mini、どちらも下記メソッドでセンサデータを取得できます

```python
GetIBSTH1Data().get_ibsth1_data(macaddr, sensortype)
```
引数「macaddr」は、センサのMacアドレスを、「sensortype」は、センサの種類に合わせ'Inkbird_IBSTH1mini'か'Inkbird_IBSTH1'を指定してください

**・実際のスクリプト**

```python:inkbird_ibsth1.py
from bluepy import btle
import struct

#Inkbird IBS-TH1データ取得クラス
class GetIBSTH1Data():
    def get_ibsth1_data(self, macaddr, sensortype):
        #デバイスに接続
        peripheral = btle.Peripheral(macaddr)
        #IBS-TH1 miniのとき
        if sensortype == 'Inkbird_IBSTH1mini':
            characteristic = peripheral.readCharacteristic(0x002d)
            return self._decodeSensorData_mini(characteristic)
        #IBS-TH1のとき
        elif sensortype == 'Inkbird_IBSTH1':
            characteristic = peripheral.readCharacteristic(0x28)
            return self._decodeSensorData(characteristic)
        else:
            return None
    
    #IBS-TH1 mini
    def _decodeSensorData_mini(self, valueBinary):
        (temp, humid, unknown1, unknown2, unknown3) = struct.unpack('<hhBBB', valueBinary)
        sensorValue = {
                'SensorType': 'Inkbird_IBSTH1mini',
                'Temperature': temp / 100,
                'Humidity': humid / 100,
                'unknown1': unknown1,
                'unknown2': unknown2,
                'unknown3': unknown3,
            }
        return sensorValue

    #IBS-TH1
    def _decodeSensorData(self, valueBinary):
        (temp, humid, unknown1, unknown2, unknown3) = struct.unpack('<hhBBB', valueBinary)
        sensorValue = {
                'SensorType': 'Inkbird_IBSTH1',
                'Temperature': temp / 100,
                'Humidity': humid / 100,
                'unknown1': unknown1,
                'unknown2': unknown2,
                'unknown3': unknown3,
            }
        return sensorValue
```

### SwitchBot温湿度計
SwitchBot温湿度計のデータ取得クラスを作成します。
内容は[こちら](https://qiita.com/c60evaporator/items/7c3156a6bbb7c6c59052#%E3%82%BB%E3%83%B3%E3%82%B5%E5%80%A4%E5%8F%96%E5%BE%97%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)と同じです

**・外部からの呼び出し法**
SwitchbotScanDelegateクラスのインスタンスを作成し、

```python
インスタンス名.scan(timeout)
```
でセンサデータを取得できます。
引数「timeout」は、データ取得のタイムアウト値を秒単位で指定してください（ざっくりですが、5秒くらいが良さそうです）

**・実際のスクリプト**

```python:switchbot.py
from bluepy import btle
import struct

#Broadcastデータ取得用デリゲート
class SwitchbotScanDelegate(btle.DefaultDelegate):
    #コンストラクタ
    def __init__(self, macaddr):
        btle.DefaultDelegate.__init__(self)
        #センサデータ保持用変数
        self.sensorValue = None
        self.macaddr = macaddr

    # スキャンハンドラー
    def handleDiscovery(self, dev, isNewDev, isNewData):
        # 対象Macアドレスのデバイスが見つかったら
        if dev.addr == self.macaddr:
            # アドバタイズデータを取り出し
            for (adtype, desc, value) in dev.getScanData():  
                #環境センサのとき、データ取り出しを実行
                if desc == '16b Service Data':
                    #センサデータ取り出し
                    self._decodeSensorData(value)

    # センサデータを取り出してdict形式に変換
    def _decodeSensorData(self, valueStr):
        #文字列からセンサデータ(4文字目以降)のみ取り出し、バイナリに変換
        valueBinary = bytes.fromhex(valueStr[4:])
        #バイナリ形式のセンサデータを数値に変換
        batt = valueBinary[2] & 0b01111111
        isTemperatureAboveFreezing = valueBinary[4] & 0b10000000
        temp = ( valueBinary[3] & 0b00001111 ) / 10 + ( valueBinary[4] & 0b01111111 )
        if not isTemperatureAboveFreezing:
            temp = -temp
        humid = valueBinary[5] & 0b01111111
        #dict型に格納
        self.sensorValue = {
            'SensorType': 'SwitchBot',
            'Temperature': temp,
            'Humidity': humid,
            'BatteryVoltage': batt
        }
```

### Nature Remo
センサデータに加え、リモコンを通じてエアコンデータも取得します。

**・外部からの呼び出し法**
**センサデータ取得：**
下記メソッドでセンサデータを取得できます

```python
get_sensor_data(Token, API_URL)
```
引数「Token」はRemoのアクセストークン（[参考](https://qiita.com/t-chi/items/01b9a9b98fbccef880c3)）、「API_URL」は"https://api.nature.global/" （固定値）を指定してください

**エアコンデータ取得**：
下記メソッドでセンサデータを取得できます

```
get_aircon_data(Token, API_URL)
```
引数はセンサデータ取得時と同様です

**・実際のスクリプト**

```python:remo.py
import json
import requests
import glob
import pandas as pd

#Remoデータ取得クラス
class GetRemoData():
    def get_sensor_data(self, Token, API_URL):
        headers = {
            'accept': 'application/json',
            'Authorization': 'Bearer ' + Token,
        }
        response = requests.get(f"{API_URL}/1/devices", headers=headers)
        rjson = response.json()
        return self._decodeSensorData(rjson)

    def get_aircon_data(self, Token, API_URL):
        headers = {
            'accept': 'application/json',
            'Authorization': 'Bearer ' + Token,
        }
        response = requests.get(f"{API_URL}/1/appliances", headers=headers)
        rjson = response.json()
        return self._decodeAirconData(rjson)

    def calc_human_motion(self, Human_last, csvdir):
        filelist = glob.glob(f"{csvdir}/*/*.csv")
        if len(filelist) == 0:
            return 0
        filelist.sort()
        df = pd.read_csv(filelist[-1])
        if df.Human_last[len(df) - 1] != Human_last:
            return 1
        else:
            return 0

    # センサデータを取り出してdict形式に変換
    def _decodeSensorData(self, rjson):
        for device in rjson:
            #Remoを選択（Remo Eを間違えて選択しないため）
            if device['firmware_version'].split('/')[0] == 'Remo':
                sensorValue = {
                    'SensorType': 'Remo_Sensor',
                    'Temperature': device['newest_events']['te']['val'],
                    'Humidity': device['newest_events']['hu']['val'],
                    'Light': device['newest_events']['il']['val'],
                    'Human_last': device['newest_events']['mo']['created_at']
                }
        return sensorValue

    # エアコンデータを取り出してdict形式に変換
    def _decodeAirconData(self, rjson):
        for appliance in rjson:
            if appliance['type'] == 'AC':
                Value = {
                    'TempSetting': appliance['settings']['temp'],
                    'Mode': appliance['settings']['mode'],
                    'AirVolume': appliance['settings']['vol'],
                    'AirDirection': appliance['settings']['dir'],
                    'Power': appliance['settings']['button']
                }
                break
        return Value
```

## ③メインスクリプト作成
### 全体構造
②のクラスを呼び出して、センサデータ取得するメインスクリプトを作成します。
構造としては、下記5種類のメソッドおよび構造に分かれています

**1)②のクラスを実行するメソッド**
getdata_omron_bag(device)： Omron環境センサBAG型のセンサ値取得
getdata_omron_usb(device)： Omron環境センサUSB型のセンサ値取得
getdata_ibsth1(device)：Inkbird IBS-TH1＆IBS-TH1 miniのセンサ値取得
getdata_switchbot_thermo(device)：SwitchBot温湿度計のセンサ値取得
getdata_remo(device, csvpath)：Nature Remoのセンサ値＆エアコンデータ取得
引数の意味は下記となります
device：DeviceList.csvから読み込んだ行を指定します。
csvpath：CSV保存パス（Remoの人感センサはCSV保存された過去データと比較が必要）

**2)CSV出力メソッド**
output_csv(data, csvpath)： センサごとの測定値データをCSV出力します。
引数の意味は下記となります
data：1のメソッドでセンサから取得したデータ
csvpath：CSV保存パス

**3)SpreadSheet出力メソッド**
output_spreadsheet(all_values_dict): 全センサの測定値データをスプレッドシートにPostします。
引数の意味は下記となります
all_values_dict：全センサのデータをdict形式で保持したもの（下のような構造を想定）

```
all_values_dict = {
    センサ名1:{
        列名1:値1,
        列名2:値2,
          ：
    },
    センサ名2:{
        列名1:値1,
        列名2:値2,
          ：
    },
    ：
}
```

**4)Bluetoothアダプタ再起動メソッド**
restart_hci0(devicename):　下の方に記載している「不具合2」対策メソッドです
引数の意味は下記となります
devicename：デバイス名（ログ出力に使用）

**5)メイン処理部分**
if _name_ == '_main_'以降： 一連の処理を下図フローのように実行します
![maincode.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/e5c7aa85-f8e9-3a88-01d5-c1adb542fef3.png)


### 設定ファイル
センサの数が増えてインコードでの管理に限界を感じたので、下記2種類の設定ファイルを作成しました
**・DeviceList.csv：センサごとに必要情報を記載**

```:DeviceList.csv
DeviceName,SensorType,MacAddress,Timeout,Retry,Offset_Temp,Offset_Humid,API_URL,Token
SwitchBot_Thermo1,SwitchBot_Thermo,[SwitchBotのMacアドレス],4,3,0,0,,
Inkbird_IBSTH1_Mini1,Inkbird_IBSTH1mini,[IBS-TH1 miniのMacアドレス],0,2,0,0,,
Inkbird_IBSTH1_1,Inkbird_IBSTH1,[IBS-TH1のMacアドレス],0,2,0,0,,
Remo1,Nature_Remo,,0,2,0,0,https://api.nature.global/,[Nature Remoのアクセストークン]
Omron_USB1,Omron_USB_EP,[Omron USB型のMacアドレス],0,2,0,0,,
Omron_BAG1,Omron_BAG_EP,[Omron BAG型のMacアドレス],3,2,0,0,,
```
カラムの意味は下記となります
DeviceName：デバイス名を管理、同種類のセンサが複数あるときの識別用
SensorType：センサの種類。この値に応じ実行するセンサデータ取得クラスを分ける
MacAddress：センサのMACアドレス
Timeout：スキャン時のタイムアウト値（ブロードキャストモードのセンサのみ、推奨値5前後）
Retry：最大再実行回数（[詳細](https://qiita.com/c60evaporator/items/283d0569eba58830f86e#%E4%B8%8D%E5%85%B7%E5%90%881peripheral%E3%81%AE%E5%88%9D%E6%9C%9F%E5%8C%96%E6%99%82%E3%81%AB%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%8C%E5%87%BA%E3%82%8B)、推奨値2～3）
Offset_Temp：温度オフセット値（現状未使用）
Offset_Humid：湿度オフセット値（現状未使用）
API_URL：APIのURL（Nature Remoのみ使用）
Token：アクセストークン（Nature Remoのみ使用）

**・config.ini：CSVおよびログ出力ディレクトリを指定**

```ini:config.ini
[Path]
CSVOutput = /share/Data/Sensor
LogOutput = /share/Log/Sensor
```
どちらも[samba](https://qiita.com/k-Mata/items/8bee9e02e74565b6c147)で作成した共有フォルダ内に出力すると、RaspberryPi外からアクセスできて便利です

## 実際のスクリプト
少し長いですが、以下に実際のスクリプトの内容を記載します。

```python:sensors_to_spreadsheet.py
from bluepy import btle
from omron_env import OmronBroadcastScanDelegate, GetOmronConnectModeData
from inkbird_ibsth1 import GetIBSTH1Data
from switchbot import SwitchbotScanDelegate
from remo import GetRemoData
from datetime import datetime, timedelta
import os
import csv
import configparser
import pandas as pd
import requests
import logging
import subprocess

#グローバル変数
global masterdate

######オムロン環境センサ（BAG型）の値取得######
def getdata_omron_bag(device):
    #値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        #omron_envのセンサ値取得デリゲートを、スキャン時実行に設定
        scanner = btle.Scanner().withDelegate(OmronBroadcastScanDelegate())
        #スキャンしてセンサ値取得
        try:
            scanner.scan(device.Timeout)
        #スキャンでエラーが出たらBluetoothアダプタ再起動
        except:
            restart_hci0(device.DeviceName)
        #値取得できたらループ終了
        if scanner.delegate.sensorValue is not None:
            break
        #値取得できなかったらログに書き込む
        else:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}, timeout{device.Timeout}]')
    
    #値取得できていたら、POSTするデータをdictに格納
    if scanner.delegate.sensorValue is not None:
        #POSTするデータ
        data = {        
            'DeviceName': device.DeviceName,        
            'Date_Master': str(masterdate),
            'Date': str(datetime.today()),
            'Temperature': str(scanner.delegate.sensorValue['Temperature']),
            'Humidity': str(scanner.delegate.sensorValue['Humidity']),
            'Light': str(scanner.delegate.sensorValue['Light']),
            'UV': str(scanner.delegate.sensorValue['UV']),
            'Pressure': str(scanner.delegate.sensorValue['Pressure']),
            'Noise': str(scanner.delegate.sensorValue['Noise']),
            'BatteryVoltage': str(scanner.delegate.sensorValue['BatteryVoltage'])
        }
        return data
    #値取得できていなかったら、ログ出力してBluetoothアダプタ再起動
    else:
        logging.error(f'cannot get data [date{str(masterdate)}, device{device.DeviceName}, timeout{device.Timeout}]')
        restart_hci0(device.DeviceName)
        return None

######オムロン環境センサ（USB型）のデータ取得######
def getdata_omron_usb(device):
    #値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        try:
            sensorValue = GetOmronConnectModeData().get_env_usb_data(device.MacAddress)
        #エラー出たらログ出力
        except:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}]')
            sensorValue = None
            continue
        else:
            break
    
    #値取得できていたら、POSTするデータをdictに格納
    if sensorValue is not None:
        #POSTするデータ
        data = {        
            'DeviceName': device.DeviceName,        
            'Date_Master': str(masterdate),
            'Date': str(datetime.today()),
            'Temperature': str(sensorValue['Temperature']),
            'Humidity': str(sensorValue['Humidity']),
            'Light': str(sensorValue['Light']),
            'Pressure': str(sensorValue['Pressure']),
            'Noise': str(sensorValue['Noise']),
            'eTVOC': str(sensorValue['eTVOC']),
            'eCO2': str(sensorValue['eCO2'])
        }
        return data
    #値取得できていなかったら、ログ出力してBluetoothアダプタ再起動
    else:
        logging.error(f'cannot get data [loop{str(device.Retry)}, date{str(masterdate)}, device{device.DeviceName}]')
        restart_hci0(device.DeviceName)
        return None

######Inkbird IBS-TH1のデータ取得######
def getdata_ibsth1(device):
    #値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        try:
            sensorValue = GetIBSTH1Data().get_ibsth1_data(device.MacAddress, device.SensorType)
        #エラー出たらログ出力
        except:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}]')
            sensorValue = None
            continue
        else:
            break

    if sensorValue is not None:
        #POSTするデータ
        data = {        
            'DeviceName': device.DeviceName,        
            'Date_Master': str(masterdate),
            'Date': str(datetime.today()),
            'Temperature': str(sensorValue['Temperature']),
            'Humidity': str(sensorValue['Humidity']),
        }
        return data
    #値取得できていなかったら、ログ出力してBluetoothアダプタ再起動
    else:
        logging.error(f'cannot get data [loop{str(device.Retry)}, date{str(masterdate)}, device{device.DeviceName}]')
        restart_hci0(device.DeviceName)
        return None

######SwitchBot温湿度計のデータ取得######
def getdata_switchbot_thermo(device):
    #値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        #switchbotのセンサ値取得デリゲートを設定
        scanner = btle.Scanner().withDelegate(SwitchbotScanDelegate(str.lower(device.MacAddress)))
        #スキャンしてセンサ値取得
        try:
            scanner.scan(device.Timeout)
        #スキャンでエラーが出たらBluetoothアダプタ再起動
        except:
            restart_hci0(device.DeviceName)
        #値取得できたらループ終了
        if scanner.delegate.sensorValue is not None:
            break
        #値取得できなかったらログに書き込む
        else:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}, timeout{device.Timeout}]')
    
    #値取得できていたら、POSTするデータをdictに格納
    if scanner.delegate.sensorValue is not None:
        #POSTするデータ
        data = {        
            'DeviceName': device.DeviceName,
            'Date_Master': str(masterdate),
            'Date': str(datetime.today()),
            'Temperature': str(scanner.delegate.sensorValue['Temperature']),
            'Humidity': str(float(scanner.delegate.sensorValue['Humidity'])),
            'BatteryVoltage': str(scanner.delegate.sensorValue['BatteryVoltage'])
        }
        return data
    #取得できていなかったら、ログ出力してBluetoothアダプタ再起動
    else:
        logging.error(f'cannot get data [loop{str(device.Retry)}, date{str(masterdate)}, device{device.DeviceName}, timeout{device.Timeout}]')
        restart_hci0(device.DeviceName)
        return None

######Nature Remoのデータ取得######
def getdata_remo(device, csvpath):
    #センサデータ値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        try:
            sensorValue = GetRemoData().get_sensor_data(device.Token, device.API_URL)
        #エラー出たらログ出力
        except:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}, sensor]')
            sensorValue = None
            continue
        else:
            break
    #エアコンデータ値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        try:
            airconValue = GetRemoData().get_aircon_data(device.Token, device.API_URL)
        #エラー出たらログ出力
        except:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}, aircon]')
            sensorValue = None
            continue
        else:
            break
        
    #値取得できていたら、POSTするデータをdictに格納
    if sensorValue is not None:
        #センサデータ
        data = {        
            'DeviceName': device.DeviceName,        
            'Date_Master': str(masterdate),
            'Date': str(datetime.today()),
            'Temperature': str(sensorValue['Temperature']),
            'Humidity': str(float(sensorValue['Humidity'])),
            'Light': str(sensorValue['Light']),
            'Human_last': str(sensorValue['Human_last']),
            'HumanMotion': GetRemoData().calc_human_motion(sensorValue['Human_last'], f'{csvpath}/{device.DeviceName}')
        }
        #エアコンデータ
        if airconValue is not None:
            data['TempSetting'] = airconValue['TempSetting']
            data['AirconMode'] = airconValue['Mode']
            data['AirVolume'] = airconValue['AirVolume']
            data['AirDirection'] = airconValue['AirDirection']
            data['AirconPower'] = airconValue['Power']
        return data
    #取得できていなかったら、ログ出力（WiFi経由なのでBluetoothアダプタ再起動はしない）
    else:
        logging.error(f'cannot get data [loop{str(device.Retry)}, date{str(masterdate)}, device{device.DeviceName}]')
        return None

######データのCSV出力######
def output_csv(data, csvpath):
    dvname = data['DeviceName']
    monthstr = masterdate.strftime('%Y%m')
    #出力先フォルダ名
    outdir = f'{csvpath}/{dvname}/{masterdate.year}'
    #出力先フォルダが存在しないとき、新規作成
    os.makedirs(outdir, exist_ok=True)
    #出力ファイルのパス
    outpath = f'{outdir}/{dvname}_{monthstr}.csv'
    
    #出力ファイル存在しないとき、新たに作成
    if not os.path.exists(outpath):        
        with open(outpath, 'w', newline="") as f:
            writer = csv.DictWriter(f, data.keys())
            writer.writeheader()
            writer.writerow(data)
    #出力ファイル存在するとき、1行追加
    else:
        with open(outpath, 'a', newline="") as f:
            writer = csv.DictWriter(f, data.keys())
            writer.writerow(data)

######Googleスプレッドシートにアップロードする処理######
def output_spreadsheet(all_values_dict):
    #APIのURL
    url = 'GAS APIのURLをここに記載'
    #APIにデータをPOST
    response = requests.post(url, json=all_values_dict)
    print(response.text)

######Bluetoothアダプタ再起動######
def restart_hci0(devicename):
    passwd = 'RaspberryPiパスワードを入力'
    subprocess.run(('sudo','-S','hciconfig','hci0','down'), input=passwd, check=True)
    subprocess.run(('sudo','-S','hciconfig','hci0','up'), input=passwd, check=True)
    logging.error(f'restart bluetooth adapter [date{str(masterdate)}, device{devicename}]')


######メイン######
if __name__ == '__main__':    
    #開始時刻を取得
    startdate = datetime.today()
    #開始時刻を分単位で丸める
    masterdate = startdate.replace(second=0, microsecond=0)   
    if startdate.second >= 30:
        masterdate += timedelta(minutes=1)

    #設定ファイルとデバイスリスト読込
    cfg = configparser.ConfigParser()
    cfg.read('./config.ini', encoding='utf-8')
    df_devicelist = pd.read_csv('./DeviceList.csv')
    #全センサ数とデータ取得成功数
    sensor_num = len(df_devicelist)
    success_num = 0

    #ログの初期化
    logname = f"/sensorlog_{str(masterdate.strftime('%y%m%d'))}.log"
    logging.basicConfig(filename=cfg['Path']['LogOutput'] + logname, level=logging.INFO)

    #取得した全データ保持用dict
    all_values_dict = None

    ######デバイスごとにデータ取得######
    for device in df_devicelist.itertuples():
        #Omron環境センサBAG型（BroadCast接続）
        if device.SensorType in ['Omron_BAG_EP','Omron_BAG_IM']:
            data = getdata_omron_bag(device)
        #Omron環境センサUSB型（Connectモード接続）
        elif device.SensorType in ['Omron_USB_EP','Omron_USB_IM']:
            data = getdata_omron_usb(device)
        #Inkbird IBS-TH1
        elif device.SensorType in ['Inkbird_IBSTH1mini','Inkbird_IBSTH1']:
            data = getdata_ibsth1(device)
        #SwitchBot温湿度計
        elif device.SensorType == 'SwitchBot_Thermo':
            data = getdata_switchbot_thermo(device)
        #remo
        elif device.SensorType == 'Nature_Remo':
            data = getdata_remo(device, cfg['Path']['CSVOutput'])
        #上記以外
        else:
            data = None        

        #データが存在するとき、全データ保持用Dictに追加し、CSV出力
        if data is not None:
            #all_values_dictがNoneのとき、新たに辞書を作成
            if all_values_dict is None:
                all_values_dict = {data['DeviceName']: data}
            #all_values_dictがNoneでないとき、既存の辞書に追加
            else:
                all_values_dict[data['DeviceName']] = data

            #CSV出力
            output_csv(data, cfg['Path']['CSVOutput'])
            #成功数プラス
            success_num+=1

    ######Googleスプレッドシートにアップロードする処理######
    output_spreadsheet(all_values_dict)

    #処理終了をログ出力
    logging.info(f'[masterdate{str(masterdate)} startdate{str(startdate)} enddate{str(datetime.today())} success{str(success_num)}/{str(sensor_num)}]')
```
※try＆except処理を多用していますが、Bluetoothの接続が不安定でエラーが多発することに由来しています。詳しくは[後述](https://qiita.com/c60evaporator/items/283d0569eba58830f86e#%E4%B8%8D%E5%85%B7%E5%90%881peripheral%E3%81%AE%E5%88%9D%E6%9C%9F%E5%8C%96%E6%99%82%E3%81%AB%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%8C%E5%87%BA%E3%82%8B)
※**クラウドDB（MongoDB Atlas）へのアップロード機能**を追加したバージョンも作成したので、興味がある方は[こちらもご参照頂ければと思います](https://qiita.com/c60evaporator/items/92d0ebde001f568f2707#2-raspberrypi%E3%81%8B%E3%82%89%E3%82%B5%E3%83%BC%E3%83%90db%E3%81%B8%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89)

## ④PythonからGASのAPIを叩いてスプレッドシートにデータ書き込み
RaspberryPiからGoogleスプレッドシートにデータを書き込みます。

RaspberryPi側ではPythonのAPI実行クラスを使ってデータをPostし、
スプレッドシート側ではGoogleAppsScriptのスクリプトを使って上記データを受け取ります

※参考にさせて頂いた記事

https://max999blog.com/python-requests-get-post-json-data/

https://qiita.com/shirakiya/items/db22de49f00710478cfc

https://www.weed.nagoya/entry/2016/05/11/105145

https://hibiki-press.tech/python/requests_module/1882#toc7

### Python側（RaspberryPi側）からのデータPOST処理
API公開にはdoPostという名前の関数しか使えないようなので、PythonからGASに一括ですべてのデータをPostする必要があります。
このような用途に合わせ、JSON形式でPostするようスクリプトを組みました

```python:sensors_to_spreadsheet.pyの一部
######Googleスプレッドシートにアップロードする処理######
def output_spreadsheet(all_values_dict):
    #APIのURL
    url = 'GAS APIのURLをここに記載'
    #APIにデータをPOST
    response = requests.post(url, json=all_values_dict)
    print(response.text)
```
肝は、「requests.post(url, json=」の後に渡す値をdictとすることです。
（json.dumps()で文字列変換しない。名前がjson=なのにjsonに変換せずに渡すとは、ハマりポイントですね‥）

### 出力先スプレッドシートの作成
[Googleスプレッドシート](https://docs.google.com/spreadsheets/)にアクセスし、
下図のようなスプレッドシートを作成します。
![sensor_spreadsheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/52c5ce0a-9e67-2ba8-b752-034c3ddc970d.png)


シート名は"SensorData"とします。このシートに全センサ一括でデータ出力するようなスクリプトを、次節で作成します


### GASスクリプト作成（データ受取とスプレッドシート書込み処理）
スプレッドシート上で「拡張機能」→「AppsScript」を選択し、
下記のようなGASスクリプトを作成します

```javascript:postSensorData.gs
var spreadsheetId = '******'//←スプレッドシートのIDを入れる

//Postされたデータを受け取ってスプレッドシートに書込むメソッド
function doPost(e){
  //スプレッドシート情報（sheetインスタンス、ヘッダ名一覧、行数）を取得
  var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('SensorData');
  var headerList = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  var lastRow = sheet.getLastRow();

  //受け取ったデータをJSONに変換
  var json = JSON.parse(e.postData.getDataAsString());

  //デバイス数を保持
  var devCnt = 0;
  for(var device in json) {
    //デバイス数をプラス1
    devCnt++;
    //全センサ測定値を走査
    for(var colName in json[device]){
      //列名が"DeviceName"のとき、値を無視（既に"device"として取得している）
      if(colName == "DeviceName"){
      //列名が"Date_Master"のとき、最初のデバイス以外は値を無視（重複記載を防ぐ）
      }else if(colName == "Date_Master" && devCnt >= 2){
      //上記以外のとき、スプレッドシートへデータ書き込み
      }else{        
        headerList = addData(sheet, lastRow, headerList, device, colName, json[device][colName]);
      }
    }
  }
  //API実行元に成功を返す
  return ContentService.createTextOutput(JSON.stringify({content:"post ok", endtime:new Date()})).setMimeType(ContentService.MimeType.JSON);
}

//スプレッドシートへのデータ書き込み
function addData(sheet, lastRow, headerList, deviceName, colName, cell){
  //カラム名がDate_Masterのとき、データ書き込み＆日付部分のみに分けたデータも書き込み
  if (colName == 'Date_Master'){
    sheet.getRange(lastRow + 1, 1).setValue(cell);
    sheet.getRange(lastRow + 1, 2).setValue(cell.substring(0, 10));
  //カラム名がDate_Master以外のとき
  }else{
    //カラム名（デバイス名と元々のカラム名を合体）  
    var newColName = deviceName + "_" + colName;
    //既存ヘッダ名を走査
    for (i=0; i<headerList.length; i++) {
      //カラム名と等しい既存ヘッダ名が存在するとき、この列にセンサデータを書き込み
      if(newColName == headerList[i]){
        sheet.getRange(lastRow + 1, i + 1).setValue(cell);
        break;
      }
      //カラム名が既存ヘッダ名に含まれない場合、新たにヘッダを追加しセンサデータ書き込み
      if(i == headerList.length - 1){
        sheet.getRange(1, i + 2).setValue(newColName);
        sheet.getRange(lastRow + 1, i + 2).setValue(cell);
        headerList.push(newColName);
      }
    }
  }
  return headerList
}
```
**※スプレッドシートのID**
スプレッドシートのURLが
"https://docs.google.com/spreadsheets/d/AAAAAA/edit#gid=0"
だとすると、
「AAAAAA」
の部分がスプレッドシートのIDとなります。

### GASスクリプトのAPI公開
[こちら](https://qiita.com/c60evaporator/items/ed2ffde4c87001111c12#python%E3%81%8B%E3%82%89gas%E3%81%AEapi%E3%82%92%E5%8F%A9%E3%81%84%E3%81%A6%E3%82%B9%E3%83%97%E3%83%AC%E3%83%83%E3%83%89%E3%82%B7%E3%83%BC%E3%83%88%E3%81%AB%E3%83%87%E3%83%BC%E3%82%BF%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF)を参考に、GASスクリプトをAPI公開してください。
最後に出てくるURLを、メインスクリプトsensor_to_spreadsheet.pyの下記部分に貼り付けると、

```python:sensors_to_spreadsheet.pyの一部
def output_spreadsheet(all_values_dict):
    #APIのURL
    url = 'GAS APIのURLをここに記載'
```
sensors_to_spreadsheet.py実行時にGASスクリプトが呼び出され、
スプレッドシートに自動で値が書き込まれます（次節のように、ヘッダも自動で追加されます）

### 出力されるスプレッドシートの説明
下図のように、
ヘッダ名：[デバイス名_値の意味] → 存在しない場合は自動追加
データ：上記ヘッダに対応する測定値を、最後尾に1行追加
といった具合に、自動でヘッダ生成、データ書込できるようGASスクリプトを組みました。
![spreadsheet_sensor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/e94e069c-d064-b8e0-6234-1a4a5fe0cdda.png)



## ⑤スクリプトの定期実行
センサの値を取得するのに、毎度スクリプトを実行するのは面倒なので、
定期実行パッケージ「cron」を使用して自動化します。
Windowsのタスクスケジューラのようなイメージです。

### cronの有効化
デフォルトだと無効になっていることがあるので、[こちら](https://qiita.com/Higemal/items/5a579b2701ef7c473062)を参考に有効化します

**・チェックポイント1：rsyslog.confの確認**
/etc/rsyslog.confの「cron」がコメントアウトされているとログが出力されません。
自分の場合下記のようにコメントアウトされていたので、

```shell
###############
#### RULES ####
###############

#
# First some standard log files.  Log by facility.
#
auth,authpriv.*                 /var/log/auth.log
*.*;auth,authpriv.none          -/var/log/syslog
#cron.*                         /var/log/cron.log
daemon.*                        -/var/log/daemon.log
```
下記のようにコメントアウトを外したのち、

```shell
cron.*                         /var/log/cron.log
```
下記コマンドでrsyslogを再起動します。

```
sudo /etc/init.d/rsyslog restart
```

**・チェックポイント2：ログレベルの変更**
/etc/default/cronで、cron実行時のログに記載する事項を指定します。
デフォルトでは下記のようにログ出力しないようなので、

```shell
# For quick reference, the currently available log levels are:
#   0   no logging (errors are logged regardless)
#   1   log start of jobs
#   2   log end of jobs
#   4   log jobs with exit status != 0
#   8   log the process identifier of child process (in all logs)
#
#EXTRA_OPTS=""
```
下記のようにEXTRA_OPTSのコメントアウトを外して

```
EXTRA_OPTS='-L 15'
```
とします。1+2+4+8=15で全部出力しますという意味です。

下記コマンドでcronを再起動します。

```
sudo /etc/init.d/cron restart
```

/var/logにcron.logが生成されていれば成功です。
（cronの実行履歴を見たい場合もこちらを確認してください）

### cronでスケジュール実行
cronで定期実行を登録します

**・crontabの編集**
下記コマンドでcrontabを開きます

```
crontab -e
```
※「sudo crontab -e」はroot実行用ファイルが開かれるので、sudoを付けないで下さい

どのエディタで開くか聞かれた場合、好きなエディタを選択します（初心者はnano推奨）

```
# Edit this file to introduce tasks to be run by cron.
# 
# Each task to run has to be defined through a single line
# indicating with different fields when the task will be run
：色々続く
```
上のように色々コメントが書いてありますが、
一番最後に、実行したいタイミングとコマンドを記載します。
タイミングのフォーマットは[こちら](http://tech.junkpot.net/archives/721/crontab-%E3%81%A7%E7%B4%B0%E3%81%8B%E3%81%84%E3%82%B9%E3%82%B1%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%81%AE%E8%A8%AD%E5%AE%9A.html)をご参照ください

今回は5分間隔で実行するため、下記内容を記載しました。

```
*/5 * * * * [Pythonのフルパス] [sensors_to_spreadSheet.pyのフルパス] >/dev/null 2>&1
```
※[こちら](https://qiita.com/noimi/items/a98985bb84d93b6819c3)のように、Pythonはフルパス指定が必要です。フルパスの例は下記
・デフォルトのPython3：/usr/bin/python3
・pyenv環境：/home/[ユーザ名]/.pyenv/shims/python
※最後の「>/dev/null 2>&1」は、メールを送信しないための処理です。[こちら](https://orebibou.com/2014/12/cron%E3%81%A7%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%82%92%E5%AE%9A%E6%9C%9F%E5%AE%9F%E8%A1%8C%E3%81%95%E3%81%9B%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AB%E6%B3%A8%E6%84%8F%E3%81%97%E3%81%9F/)参照

**・cronの起動**
下記コマンドでcronを起動します

```
sudo /etc/init.d/cron start
```

上記でしばらく待ち、5分ごとにスプレッドシートが更新されていれば完了です！
**※うまくいかない**
/var/log/syslogにあるcronのログを見て対処してください。
私の場合、「crontab -e」記載内容に、sensors_to_spreadSheet.pyのあるフォルダへの移動を加え

```
*/5 * * * * cd [sensors_to_spreadSheet.pyのあるフォルダ]; [Pythonのフルパス] [sensors_to_spreadSheet.pyのフルパス] >/dev/null 2>&1
```
とするとうまくいく事が多かったです

## ⑥Google Data Portalによる可視化
[Googleデータポータル](https://datastudio.google.com/navigation/)とは、クラウド上で編集、閲覧できるダッシュボードです。
本節では、冒頭のダッシュボードのうち、温度推移グラフ部分の作成法を解説します

### 新たなレポートの作成
![1_dataportal.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/ac604cfb-35d9-2939-ebce-347c2ba52fdf.png)
**データ接続先に、Googleスプレッドシートを指定します**
![2_connect_spreadsheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/af3fc5c6-f8d7-06af-842f-9b705a8b026f.png)
**承認を求められた場合、承認ボタンを押します**
![3_allow_spreadsheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/490dbd9e-2270-7497-e730-456940e86a79.png)
**参照先のシートを求められるので④で作ったスプレッドシートを指定します**
![4_add_spreadsheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/e8e85511-b8a3-89cd-e40f-cc67988002f8.png)
**「レポートに追加」を押します**
![5_confirm_spreadsheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/5cb65a45-2e36-ee8b-24ca-bd0af9ad625a.png)
**レポート名を変更します**
![6_rename.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/288a4ecc-ed81-ceca-7cfe-470b1c9d1b7e.png)

### 日平均グラフの作成
長期的な変化を見るグラフを作成します

**「リソース」→「追加済みのデータソースの管理」をクリックします**
![7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/2924822b-4044-f03d-f776-7e15a88bad78.png)
**対象のデータソースの「編集」をクリックします**
![8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/5ff46665-1f9d-70b2-737f-1d579ed66149.png)
**日時を、認識できる形式に変更します**（データポータルは日時の認識フォーマットが厳しいです）
平均などの統計値を正しく出したい場合：YYYYMMDD（日付）
1行ごとの計測値を表示させたい場合：YYYYMMDDhhmm（日付、時、分）
→**2022年1月追記**: 画像では「年月日時分(YYYYMMDDhhmm)」となっていますが、現在は「日付、時、分」という名称に変わり、YYYYMMDDhhmmという表記も併記されなくなったようです
![9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/59510a37-50d3-7dcf-9a14-1b07e36427ab.png)
**時系列グラフに変更します**
![10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/356d25c1-8f59-f40b-2a1c-1a6a95a75ef0.png)
**ディメンジョン（横軸=Date_Master_Day）と指標（縦軸=各種センサの気温）を指定します**
![11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/a14a98e7-e2b3-5fb7-6848-986dd711dddf.png)
**気温の統計値を合計 → 平均に変更します**
![12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/2b72b05c-5707-cd00-2d8b-efa133af8aab.png)
**欠損値を表示しないよう変更します**
![13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/722fe750-2a32-fbef-0cef-640b224168b3.png)

### 1計測ごと（5分ごと）グラフの作成
短期的な変化を見るグラフを作成します

**時系列グラフを追加します**
![15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/25d19595-e53d-3e90-8ed9-58b833982d26.png)
**ディメンジョン（横軸=Date_Master_Day）と指標（縦軸=各種センサの気温）を指定します**
![16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/06f93db1-fe31-45f1-5077-0e608d6ebe88.png)
**気温の統計値を合計 → 平均に変更します**
![17_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/44f342a8-04bd-e972-80dd-ed5bd723b3b2.png)
**欠損値を線形補完するよう変更します**
![18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/99ee2d11-7e86-8ded-d06a-cba2232cbd0b.png)
これで、温度推移グラフが完成です！

### ダッシュボードを完成させるために
この時点で完成しているのは、冒頭ダッシュボードのうち下記赤枠で囲った部分のみです。
![DashBoard_part.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/9c1c8c12-397b-d5a6-3b38-083ea1548e82.png)

完成には上記以外に「湿度推移グラフ」と「円グラフ」を作る必要がありますが、
全てを解説すると長くなるので割愛します。

湿度推移グラフは、温度推移グラフの「指標」を温度から湿度に変えるだけなので、特に詰まるところもなく作成できると思います。

円グラフの作成方法は、[こちら](https://qiita.com/t-chi/items/01b9a9b98fbccef880c3#%E6%B9%BF%E5%BA%A6%E3%81%AE%E5%86%86%E3%82%B0%E3%83%A9%E3%83%95)にわかりやすくまとめられております。

最初に「飛躍しない！」と言いつつ3分クッキング的な飛ばし方をしてしまい申し訳ないですが、
色々頑張ってグラフを完成させ、レイアウトを整えると以下のようなダッシュボードを作る事ができます。
![DashBoard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/ba1dc893-9375-15bb-442c-708441b386ea.png)

**以上で完成です！！**
奇をてらった部分もなく、「どこがスーパーやねん！」と突っ込みたい人がいるかもしれませんが、
低価格のガジェットや無料サービスを組み合わせてここまで出来たのは、個人的には感動でした。

料理をしたら湿度が上がったり、気温が季節とともにきれいな上昇曲線を描いていたりと、新しい発見もあり、見ていて飽きない仕上がりになったとは思います！

# 各種エラー対応
あらゆるシステムに言える事ですが、連続稼働すると単発では気づかなかった多種多様な不具合が顕在化します。
本件では、特に**Bluetooth受信の不安定さ**に非常に悩まされました。

発生した不具合には個々に対処した上で、各種スクリプトに反映させています。
対処前は無数な停止や欠損値が発生しましたが、下記対処により、希に欠損値は出ますが、**ほぼノーメンテで連続稼働できるレベル**まで改善できました！

## 不具合1：peripheralの初期化時にエラーが出る
### 発生する現象

```python:inkbird_ibsth1.pyの一部
    def get_ibsth1_data(self, macaddr, sensortype):
        #デバイスに接続
        peripheral = btle.Peripheral(macaddr)
        #IBS-TH1 miniのとき
        if sensortype == 'Inkbird_IBSTH1mini':
            characteristic = peripheral.readCharacteristic(0x002d)
```
上記の
peripheral = btle.Peripheral(macaddr)
のところで、

```
BTLEException: failed to connect to peripheral addr type: public
```
というエラーが出たり出なかったりします（再現性がないのが厄介です）

### 対処
再実行すれば何事もなかったように正常に動くことが多いので、
呼び出し側でtry exceptで例外を拾ってあげて、一定回数までは再実行するようなループ処理としました。

```python:sensors_to_spreadsheet.pyのgetdata_ibsth1メソッドの一部
def getdata_ibsth1(device):
    #値が得られないとき、最大device.Retry回スキャンを繰り返す
    for i in range(device.Retry):
        try:
            sensorValue = GetIBSTH1Data().get_ibsth1_data(device.MacAddress, device.SensorType)
        #エラー出たらログ出力
        except:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}]')
            sensorValue = None
            continue
        else:
            break
```
再実行回数は、設定ファイル「DeviceList.csv」の「Retry」列でデバイスごとに指定するようにしました。

## 不具合2：ブロードキャストモードでのスキャン時エラー
### 発生する現象
```python:getdata_switchbot_thermoメソッドの一部
        #スキャンしてセンサ値取得
        try:
            scanner.scan(device.Timeout)
        #スキャンでエラーが出たらBluetoothアダプタ再起動
        except:
            restart_hci0(masterdate, device.DeviceName)
```

上記のscanner.scan()を繰り返していくと20回実行したくらいで、

```
bluepy.btle.BTLEManagementError: Failed to execute management command 'scan' (code: 11, error: Rejected)
```
というエラーが発生して、そのまま何度再実行してもエラーでデータが取得できなくなります。
実行回数が10回で発生するときもあれば、34回のときもある、といった感じで回数の再現性もなく、非常に厄介です。
（原因は不明ですが、セクションのようなものがが溜まっている挙動に見えます）

### 対処
[こちら](https://github.com/IanHarvey/bluepy/issues/150)を参考にして、下記コマンド（Bluetoothアダプタの再起動）

```
sudo hciconfig hci0 down
sudo hciconfig hci0 up
```
を実行するとエラーから復旧することが分かったので、[こちら](https://qiita.com/dosec/items/b2b4a9018dd2978a1df8)を参考に

```python:
def restart_hci0(masterdate, devicename):
    passwd = 'パスワードを入力'
    subprocess.run(('sudo','-S','hciconfig','hci0','down'), input=passwd, check=True)
    subprocess.run(('sudo','-S','hciconfig','hci0','up'), input=passwd, check=True)
    logging.error(f'restart bluetooth adapter [date{str(masterdate)}, device{devicename}]')
```

というメソッドを作りました。
※上記のままではパスワード直打ちでセキュリティリスクがあるので、[こちら](https://www.tsuyukimakoto.com/blog/2013/12/02/hide_password/)参考に適宜隠蔽してください

これを、

```python:getdata_switchbot_thermoメソッドの一部
        #スキャンしてセンサ値取得
        try:
            scanner.scan(device.Timeout)
        #スキャンでエラーが出たらBluetoothアダプタ再起動
        except:
            restart_hci0(masterdate, device.DeviceName)
```
のようにエラー発生時に実行し、自動で再起動、復旧できるような仕組みとしました。

上記コマンドは不具合1の改善にも効くようなので、Retry回数内に値が取得できなかった場合も実行するようにしました。
**困ったときの再起動**の万能さには脱帽ですね！


## 不具合3：値が取得できなかったときに、原因を追跡できない
※こちらは不具合というより運用上のネックです
### 発生する現象
不具合1に対して再実行を繰り返しても、Retry回数内に値が取得できないこともあります。

また、SwitchBotのようなブロードキャストモードではPeripheral関係のエラーは起こりませんが、不具合2が起こりますし、タイムアウト値以内に値が返って来ず、やはりRetry回数内に値が取得できないこともあります

何もしなければ、値が取得できなかった事に気付けませんし、故障により単発ではなくずっと値が取得できなくなっている場合、いつから故障したのかを後から追うこともできません

### 対処法
値が取得できなかったことを記録して、後から原因を追えるよう、[こちら](https://qiita.com/__init__/items/91e5841ed53d55a7895e)を参考に、下記4パターンのログを出力するようにしました。
**パターン1：不具合1発生時のログ出力部分**

```python:sensors_to_spreadsheet.pyのgetdata_omron_usbメソッドの一部
        #エラー出たらログ出力
        except:
            logging.warning(f'retry to get data [loop{str(i)}, date{str(masterdate)}, device{device.DeviceName}]')
```

**パターン2：不具合2発生時のログ出力部分**

```python:sensors_to_spreadsheet.pyのrestart_hci0メソッドの一部
    logging.error(f'restart bluetooth adapter [date{str(masterdate)}, device{devicename}]')
```

**パターン3：規定回数Retryしても値が取得できなかったときのログ出力部分**

```python:sensors_to_spreadsheet.pyのgetdata_omron_usbメソッドの一部
    #値取得できていなかったら、ログ出力してBluetoothアダプタ再起動
    else:
        logging.error(f'cannot get data [loop{str(device.Retry)}, date{str(masterdate)}, device{device.DeviceName}]')
```

**パターン4：一連の処理が完了したときのログ出力部分**

```python:sensors_to_spreadsheet.pyのメインコードの最後
    #処理終了をログ出力
    logging.info(f'[masterdate{str(masterdate)} startdate{str(startdate)} enddate{str(datetime.today())} success{str(success_num)}/{str(sensor_num)}]')
```

ログの出力先は、設定ファイル「config.ini」の「LogOutput」で指定します。

# トラブルシューティング
前章のような工夫で、（try-exceptは裏技感がありますが）ほぼ停止せず連続測定できるようになりました。
しかし、停止以外にもセンサ値取得ミス（欠損値）の頻発など、原因が明確でない不具合も発生します。
そんなときは、下記対処が有効な事が多いと感じました。

### 困ったときの再起動
下記のように、関連機器を再起動すると、嘘のようにトラブルが改善することが多くあります
・RaspberryPiをreboot
・センサの電池抜き差し

### 距離や障害物の確認
センサとRaspberryPiの距離が遠い、あるいは間に障害物があると、欠損値が明確に増えます。
距離を近づけたり、障害物を撤去したりして状況が改善するかを確認してください。

BLEにおいて一般的なClass2は、そもそも通信範囲が10mと狭いので、
1台のRaspberryPiで全てをカバーしようとせず、部屋ごとにRaspberryPiを準備する等の工夫も有効です。

また、ブロードキャスト型（Omron BAG型およびSwitchBot）の場合、タイムアウト値を大きくすると、距離が遠い場合の取得成功率が上がることがあります。

### 電池切れの確認
次章で触れますが、電池切れ直前にエラーが多発することがあるので、電池交換してみるのも手です。

# やってみて感じたこと
### 長期安定動作の難しさ
データを取得すること以上に、長期安定動作を確保するのが大変でした。
特にBluetooth受信まわりが不安定で、再現性のないエラーが容赦なく心を折りにきます(笑)
try-except処理は上記問題解決に非常に有用ですが、乱用すると不具合に気付けなくなる原因となるので、注意して使用したいと思います。

### 拡張性の大切さ
デバイス管理を設定ファイルでリスト化＆スプレッドシートのカラム生成を自動化することで、
センサを増やした時の対応がとても楽となりました。
拡張性の要件は後回しにされがちですが、設計の初期段階で入念に考えておいた方が、後々苦労しないと実感しました！
#### 2020/8/9追記
データベースにアップロードするときに、HumidityがFloat型のものとInt型のものが混在していたことが原因で、
処理が統一化できないという問題が発生し、上記メインコードにFloat型に統一する処理を追加しました。
拡張性の観点からは、「**同種の値はセンサが異なっても同じ型で定義する**」ことが重要だと感じました。

### セキュリティ問題の根の深さ
パスワードの隠蔽等、セキュリティを確保しようとすると実装、運用が複雑となるので、
利便性とのトレードオフ関係のバランスが難しいと感じました。
センサデータから人の不在や何をしているかもある程度わかってしまうので、プライバシー的な不安もあります。
（まさにスーパーシティ法でも問題となっている部分ですね）

### センサごとの感想
※1機種1個しか試していないので、センサの差ではなく個体差かもしれない事にご留意ください
**Omron**：Made in Japanだけあり、USB型、BAG型共に安定しています（他のセンサの倍以上の値段なので当たり前ですが）
**Inkbird IBS-TH1 mini**：欠損値が多い、電池が1ヵ月持たない等難はあるが、小型である事は魅力
**miniじゃないIBS-TH1**：Omronほどではないにしろ、そこそこ安定しています。電池も持つので個人的には**イチオシ**
**SwitchBot**：こちらもIBS-TH1 mini同様不安定ですが、マイナス異常値が出ない事や電池の持ち、価格の安さを考えると悪くない選択肢だと思いました。
**Nature Remo**：Wifi接続で安定していますが、計測の粒度が粗いのがネックです。このデバイスはセンサではなくリモコンがメインなので、仕方ないとは思います。

### 電池切れの予兆
Inkbird IBS-TH1 miniは1ヵ月持たずに電池切れという、なかなかのヘタレっぷりを発揮しましたが、
電池切れ2日前くらいから下の図のピンク線のような異常値（温度=-46.2℃、湿度=0%）を頻発するようになりました
![InkbirdMini_異常値.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/610167/4f5a1be6-7345-722b-c3e6-dbf174fbac17.png)

これが再現性のある電池切れの予兆なら、完全に切れる前に交換して欠損値を防げるので、非常に有用な情報です。
次の電池切れでも直前の様子は注意深く観察しようと思います。

ちなみにInkbirdアプリで取得した電池残量は、この時点でも40%くらい残っていたので、全く当てにならない事が分かりました（笑）

以上のような知見を、今後の開発に活かしていきたいと思います！