Raspberry Pi＋PythonでLoRaWAN Deviceにチャレンジしてみる（４）

#今回の内容
[前回](https://qiita.com/nagase/items/b80db2d7beb4c4cad4e8)からの続きで、今回はラズパイにセンサーを取り付け、そのデータをプラットフォームへ送信し、最終的にAmazon S3に保存されるかやってみます。
また設置場所ですが、「Fukuoka City LoRaWAN」の提供エリア内にあり、アクセスも良くて環境も整っている「[GooDay Fab 大名店](https://ja-jp.facebook.com/goodayfabdaimyo/)（福岡市中央区大名、FUKUOKA growth next 1F）」に設置することにしました。
更にセンサーについては、そこにある植物コーナーの鉢植の土壌水分を定期的（10分毎）に測定し、データを送信することにします。

#センサー周りで準備した物

色々検討した結果、以下の物を準備すれば簡単＆短時間で組み立て出来ることがわかり、通販で即買いしました。

- [GrovePi+](https://www.switch-science.com/catalog/2129/)  
    Raspberry Piにこのセンサーボードを付けるだけで、はんだ付けすることなくGroveブランドのセンサーを取り付けできます。  
    また、A/Dコンバータも搭載されているので、アナログの入出力もデジタルに変換してくれます。
- [Grove 水分センサ](https://www.switch-science.com/catalog/814/)
- [GrovePi ケース](https://www.dexterindustries.com/shop/grovepi-case/)  
    基盤剥き出しだと取り扱いが気になっていましたが、GrovePi専用ケースが売っていました。
- [GROVE 4ピンケーブル 50cm (5本セット)](https://www.switch-science.com/catalog/797/)  
    センサ付属のケーブルが短い（20cm程度）ので、50cmを別途調達しました。

#組み立て
GrovePiはラズパイに載せたとき、USBポートやLANポートの金属部分がGrovePiの基盤部分に当たってしまうので、以下画像の位置にマスキングテープを貼って絶縁対策します。
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/421c697a-af5c-0a15-a0bf-d914aa1e59a9.jpeg" width="50%">

GrovePiコネクタ部分をラズパイのGPIOピンに差し込んで取り付けます。ちなみに差し込み位置は下図の通りです。
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/50b28971-c425-d2d8-7dd8-ac3d5cdc39aa.jpeg" width="50%">

最後にGrovePiケースに収めた後、センサーや各種ケーブルを取り付けます。水分センサーはアナログポートの0番（基盤に`A0`と書かれています）に差し込みます。
尚、ケースへの取り付け、組み立て方法は[公式動画](https://youtu.be/L-agV6CDrsY)を参考にしました。
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/6910a296-b839-dd45-ee8b-420dba539610.jpeg" width="50%">

#ラズパイ起動時に不具合発生
組み立てが終わりラズパイの電源を投入しますが、ここで問題が発生しハマりました。
起動途中からレジスタやスタック情報が表示され、最後に

```
Fixing recursive fault but reboot is needed.
```

というメッセージか表示され、起動できません。
何度か、接続が間違っていないか確認して電源を再投入してみますが、やはり同じ症状です。

試しに、GrovePiを取り外して電源を投入すると、こちらは問題なく起動できます。

「もしかすると、カーネルのバージョンが関係している？」と思い、色々調べてみると、、、ありました。
GrovePiの公式サイトに「[Raspbian for Robots](https://www.dexterindustries.com/howto/install-raspbian-for-robots-image-on-an-sd-card/)」というページがあり、そこでGrovePi用にカスタマイズされたOSイメージが配布されていました。

試しに上記ページの`Using a PC`を展開し、表示された手順に従って別のSDカードを準備して起動してみると、問題なく起動しました。

ちなみに起動できなかったSDカードは、元々「[Complete Starter Kit](http://amzn.asia/9vutlbE)」に同梱されていたもので、OSのバージョンを確認すると最新バージョンの`stretch`だということが解りました。

```
$ cat /etc/os-release
PRETTY_NAME="Raspbian GNU/Linux 9 (stretch)"
NAME="Raspbian GNU/Linux"
VERSION_ID="9"
VERSION="9 (stretch)"
ID=raspbian
ID_LIKE=debian
HOME_URL="http://www.raspbian.org/"
SUPPORT_URL="http://www.raspbian.org/RaspbianForums"
BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs"
```

また起動した`Raspbian for Robots`のバージョンを確認すると、

```
$ cat /etc/os-release
PRETTY_NAME="Raspbian GNU/Linux 8 (jessie)"
NAME="Raspbian GNU/Linux"
VERSION_ID="8"
VERSION="8 (jessie)"
ID=raspbian
ID_LIKE=debian
HOME_URL="http://www.raspbian.org/"
SUPPORT_URL="http://www.raspbian.org/RaspbianForums"
BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs"
```

ひとつ前のバージョン`jessie`だということが解り、現時点（2017/11現在）で、GrovePiは`stretch`に未対応だということが推測できます。

ということで、切ない状況ですが前回まで使っていたOSは捨てて、この`Raspbian for Robots`で構築していくことにします。

ところでこの`Raspbian for Robots`ですが、幸いにしてGrovePiを利用すためのツールやモジュール、サンプルスクリプト等は一通りインストール済みですので、公式の`Raspbian Jessie`を準備して環境を整えることを考えると、多少の手間は省けます。

#GrovePiファームウェアのアップデート
無事に起動できましたのでログインします。
尚、公式のRaspbianのデフォルトログインID、PWはそれぞれ`pi`、`raspberry`ですが、`Raspbian for Robots`の場合、IDは同じ`pi`ですが、パスワードは`robots1234`となります。

ログインできたらOSの基本的な環境（ネットワーク、ロケール、タイムゾーン等々）を設定した後、GrovePiのファームウェアをアップデートします。

まずは現在のファームウェアのバージョンを、`grove_firmware_version_check.py`スクリプトで確認します。

```
$ cd ~/Dexter/GrovePi/Software/Python
$ python grove_firmware_version_check.py
GrovePi has firmware version 1.2.2
```

次に`firmware_update.sh`コマンドで最新バージョンにアップデートします。

```
$ cd ~/Dexter/GrovePi/Firmware
$ sudo chmod +x firmware_update.sh
$ sudo ./firmware_update.sh
```

再度、ファームウェアのバージョンを確認します。

```
$ cd ~/Dexter/GrovePi/Software/Python
$ python grove_firmware_version_check.py
GrovePi has firmware version 1.2.7
```

また、ここまでの作業でOSを最新の状態にしていない場合、次のコマンドでアップデートします。

```
$ sudo apt-get update
$ sudo apt-get upgrade
```

更新後は念のためOSをリブートします。

#センサーテスト

ここまでの作業で環境が整いましたので、センサーをテストしてみます。

まず、OSがGrovePiをi2cボードとして認識しているか、次のコマンドで確認します。

```
$ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:          -- 04 -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
```

00行の4列目が`04`となっていますので、きちんと認識してくれているようです。

既にインストールされているDemoスクリプトを使って、センサーデータを取得してみます。
センサーの種類別にDemoスクリプトが用意されていますので、今回は水分センサ用のDemoスクリプト`grove_moisture_sensor.py`を使います。

```
$ cd ~/Dexter/GrovePi/Software/Python
$ python grove_moisture_sensor.py
0
0
0
83
67
74
･･･
```

問題なくセンサーからデータを取ってくれているようです。

ちなみに、水分センサーの両方の金属部分をショートさせるように指で触ると通電して数値が上がり、触らなければ0になります。

またDemoスクリプトの中を見ると以下の説明があり、このセンサーは0～950までの値をとり、その数値により「乾燥(0-300)」「多湿(300-700)」「水中(700-950)」の状態を判別できることが解ります。

```
# NOTE:
# 	The wiki suggests the following sensor values:
# 		Min  Typ  Max  Condition
# 		0    0    0    sensor in open air
# 		0    20   300  sensor in dry soil
# 		300  580  700  sensor in humid soil
# 		700  940  950  sensor in water
	
# 	Sensor values observer: 
# 		Val  Condition
# 		0    sensor in open air
# 		18   sensor in dry soil
# 		425  sensor in humid soil
# 		690  sensor in water
```

#LoRaWANモデムの動作確認
OSが変わりましたので、LoRaWANモデムと問題なく通信できるか再確認します。

まずは前回までに作成した以下のPythonスクリプトを、`~/Projects/LoRaWAN`直下に置きます。

- RHF3M076.py
- main.py

次に[第1回の手順](https://qiita.com/nagase/items/230b03ef09cbc5bbbd0c#%E3%83%A2%E3%83%87%E3%83%A0%E3%82%92%E8%AA%8D%E8%AD%98%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B%E7%A2%BA%E8%AA%8D)に従って、LoRaWANモデムのデバイスポート(`/dev/ttyACM0`)を確認します。
特に違いもなく支障はなさそうですので、`main.py`を実行してみます。

```
$ cd ~/Projects/LoRaWAN
$ ./main.py
（中略）
AttributeError: 'Serial' object has no attribute 'reset_input_buffer'
```

`Serial`オブジェクトに`reset_input_buffer`アトリビュートが無い、と叱られてしまいました。

「もしかするとインストールされている`pySerial`のバージョンが古い？」と思ったので確認してみます。

```
$ pip3 show pyserial | grep Version
Version: 2.6
```

やはりバージョンが古いようですので、アップグレードしてみます。

```
$ sudo pip3 install --upgrade pyserial
Downloading/unpacking pyserial from https://pypi.python.org/packages/0d/e4/2a744dd9e3be04a0c0907414e2a01a7c88bb3915cbe3c8cc06e209f59c30/pyserial-3.4-py2.py3-none-any.whl#md5=0e555d61700e0b95a15d8162092c5299
  Downloading pyserial-3.4-py2.py3-none-any.whl (193kB): 193kB downloaded
Installing collected packages: pyserial
  Found existing installation: pyserial 2.6
    Not uninstalling pyserial at /usr/lib/python3/dist-packages, owned by OS
Successfully installed pyserial
Cleaning up...
```

まさかの`owned by OS`と表示され、アップデートできません。

`pySerial`はハードウェア絡みのモジュールということもあり、無理矢理バージョンアップしてGrovePiが動作しなくなる恐怖もあるので、ここは`RHF3M076.py`の該当行をコメントアウトすることで対応することにしました。

```
    def _open(self):
        （中略）
        try:
            （中略）
            self._modem.reset_input_buffer()

↓↓↓↓↓↓
    def _open(self):
        （中略）
        try:
            （中略）
            #self._modem.reset_input_buffer()
```

#LoRaWANデバイスの設置

スクリプトを作成する前に、LoRaWANデバイスを[GooDay Fab 大名店](https://ja-jp.facebook.com/goodayfabdaimyo/)に設置します。

植物コーナーの棚に空きスペースがあり、電源も近くにあったのでそこに置かせてもらいました。
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/34e08040-0977-a280-afc7-e6f171968965.jpeg" width="50%">

折角なので、同店で販売しているアクリル板をレーザーカッターで加工し、店ロゴ入りの上板に変更してみました。
レーザーカッターを使った作業って、意外と楽しかったりします（笑）
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/6323d208-d079-175c-e556-c4465bd4f38c.jpeg" width="50%">

そばにあるポトスの鉢に水分センサーを差し込みました。ちなみに水分センサーの基盤部分が剥き出しだったので、ビニールテープでマスキングしています。
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/c08a58cd-59ff-0681-35c0-741b7981a8cc.jpeg" width="50%">

さらに大名店のスタッフが、レーザーカッターで専用プレートを作ってくれました！
<img src="https://qiita-image-store.s3.amazonaws.com/0/94476/926b64ff-8194-40a6-28ec-cdf1382f4f7e.jpeg" width="50%">




#センサーデータ送信用スクリプト作成

Demoスクリプトを参考に、センサーデータをプラットフォームへ送信するスクリプトを作成したいと思います。

まずはモジュール`RHF3M076.py`の`sendPayload`メソッドを以下コードのように変更し、mtype（ACKの確認/無視）とdtype（Text or Hex）のフラグを追加して、より汎用的に使えるようにしました。

```python:RHF3M076.py
#!/usr/bin/env python3
# coding: utf-8
#------------------------------------------------------------------------------
# LoRaWAN modem (RHF3M076) module for Fukuoka City LoRaWAN
#     by Kaho Musen Holdings Co.,Ltd.
#
#     Created: 2017.11.03
#      Author: k.nagase (a) gooday.co.jp
#------------------------------------------------------------------------------

import serial
import re
import time
from logging import getLogger

# Class of LoRaWAN modem
class RHF3M076:

    # Constructor
    def __init__(self, port='/dev/ttyACM0', baud=115200, timeout=0.1):
        self._port = port
        self._baud = baud
        self._timeout = timeout
        self._crlf = '\r\n'
        self._ptnDevAddr = r'\+ID: DevAddr, (([0-9A-Fa-f]{2}[:-]){3}[0-9A-Fa-f]{2})'
        self._ptnDevEui = r'\+ID: DevEui, (([0-9A-Fa-f]{2}[:-]){7}[0-9A-Fa-f]{2})'
        self._ptnAppEui = r'\+ID: AppEui, (([0-9A-Fa-f]{2}[:-]){7}[0-9A-Fa-f]{2})'
        self._ptnKeyErr = r'\+KEY: ERROR\((.*)\)'
        self._logger = getLogger(type(self).__name__)
        
        self._open()

    # Open the serial port
    def _open(self):
        self._modem = serial.Serial()
        self._modem.port = self._port
        self._modem.baudrate = self._baud
        self._modem.timeout = self._timeout

        try:
            self._modem.open()
            #self._modem.reset_input_buffer()
            return()
        except Exception as e:
            self._logger.error('Serial port open failed.')
            raise(e)

    # Send key type and key value
    def _setKey(self, KeyType, KeyValue):
        try:
            if KeyValue:
                cmd = 'AT+KEY=' + KeyType + ',"' + KeyValue + '"'
                ret = self._write(cmd)
                self._waitResponse()
                line = self._read()
                ret = re.match(self._ptnKeyErr, line)
                if ret:
                    raise Exception(KeyType + ' is invalid. (' + ret.group(1) + ')')
            else:
                raise Exception(KeyType + ' is empty.')
            return()
        except Exception as e:
            self._logger.error(e)

    # Send to serial port
    def _write(self, cmd):
        try:
            self._logger.info('SEND:' + cmd)
            cmd = cmd + self._crlf
            ret = self._modem.write(cmd.encode())
            return(ret)
        except Exception as e:
            print('ERROR: Send to serial port failed.')
            raise(e)

    # Receive from serial port
    def _read(self):
        len = self._waitResponse()
        ret = self._modem.readline().decode().replace(self._crlf, '')
        self._logger.info('RECV:' + ret)
        return(ret)
    
    def _waitResponse(self):
        while self._modem.inWaiting() == 0:
            time.sleep(0.1)
        return(self._modem.inWaiting())

    # Send payload
    def sendPayload(self, mtype, dtype, payload):
        #
        # mtype: 0=ACK Unconfirm, 1=ACK Confirm
        # dtype: 0=Text, 1=Hex
        #
        cmd = 'AT+'
        if mtype:
            cmd += 'CMSG'
        else:
            cmd += 'MSG'
        if dtype:
            cmd += 'HEX'
        cmd += '="' + payload + '"'
        self._write(cmd)
        #time.sleep(2)
        len = self._waitResponse()
        if mtype:
            ack = False
        else:
            ack = True
        while True:
            line = self._read()
            if line.find('Done') != -1:
                break
            if line.find('ACK Received') != -1:
                ack = True
        return(ack)

    @property
    def DevAddr(self):
        return(self._DevAddr)

    @property
    def DevEui(self):
        return(self._DevEui)

    @property
    def AppEui(self):
        return(self._AppEui)

    @property
    def NwksKey(self):
        return(self._NwksKey)

    @property
    def AppsKey(self):
        return(self._AppsKey)

    @property
    def AppKey(self):
        return(self._AppKey)

    @property
    def ADR(self):
        return(self._ADR)

    @NwksKey.setter
    def NwksKey(self, NwksKey):
        self._NwksKey = NwksKey
        self._setKey('NWKSKEY', self._NwksKey)
    
    @AppsKey.setter
    def AppsKey(self, AppsKey):
        self._AppsKey = AppsKey
        self._setKey('APPSKEY', self._AppsKey)

    @AppKey.setter
    def AppKey(self, AppKey):
        self._AppKey = AppKey
        self._setKey('APPKEY', self._AppKey)

    @ADR.setter
    def ADR(self, state):
        self._ADR = state
        strState = 'ON' if self._ADR else 'OFF'
        cmd = 'AT+ADR=' + strState
        ret = self._write(cmd)
        len = self._waitResponse()
        self._read()

    # Destructor
    def __del__(self):
        self._modem.close()
```

そしてセンサーデータ取得＆LoRaWAN送信用のスクリプト`moisture.py`を、以下コードのように作成しました。
尚、正確に一定間隔でデータ取得から送信まで行いたいので、[こちら](https://qiita.com/miminashi/items/50a4f0906ab8f18b105d)の投稿を参考に、システムコールとシグナルを使って一定間隔で処理させるようにしました。
また送信エラーが発生した場合、最大3回までリトライするようにしています。

```python:moisture.py
#!/usr/bin/env python3
# coding: utf-8

import signal
import time
import grovepi
from RHF3M076 import RHF3M076
from logging import basicConfig, getLogger, DEBUG

# Moisture sensor port number (Analog #0)
sensor = 0

# Seconds and interval for signal
sgSecond = 0.1
sgInterval = 600.0

# Keys
NwkSKey = '2BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
AppSKey = '2BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
AppKey = '2BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'

modem = RHF3M076()
basicConfig(level=DEBUG)
logger = getLogger(__name__)

def getSensor():
    try:
        # Get moisture sensor data (0 - 950)
        val = grovepi.analogRead(sensor)
        return(str(val))
    except IOError:
        logger.error('Sensor input failed.')
        return(False)

def task(arg1, arg2):
    payload = getSensor()
    #print(payload)
    modem.NwksKey = NwkSKey
    modem.AppsKey = AppSKey
    modem.AppKey = AppKey
    modem.ADR = True

    # Send LoRaWAN payload (Retry at 3 times)
    for i in range(3):
        if modem.sendPayload(1, 0, payload): break
    else:
        logger.error('Payload send failed.')
        time.sleep(1)

def main():
    signal.signal(signal.SIGALRM, task)
    signal.setitimer(signal.ITIMER_REAL, sgSecond, sgInterval)

    while 1:
        try:
            time.sleep(1)
        except KeyboardInterrupt:
            modem = None
            break
        except Exception as e:
            modem = None
            raise(e)

if __name__ == '__main__':
    main()
```

#スクリプトの実行

実際にスクリプトを実行してみます。
まず初めに`moisture.py`に実行権限を付与し、その後実行します。

```
$ chmod +x moisture.py
$ ./moisture.py
INFO:RHF3M076:SEND:AT+KEY=NWKSKEY,"2BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
INFO:RHF3M076:RECV:+KEY: NWKSKEY 2B XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX
INFO:RHF3M076:SEND:AT+KEY=APPSKEY,"2BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
INFO:RHF3M076:RECV:+KEY: APPSKEY 2B XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX
INFO:RHF3M076:SEND:AT+KEY=APPKEY,"2BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
INFO:RHF3M076:RECV:+KEY: APPKEY 2B XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX
INFO:RHF3M076:SEND:AT+ADR=ON
INFO:RHF3M076:RECV:+ADR: ON
INFO:RHF3M076:SEND:AT+CMSG="338"
INFO:RHF3M076:RECV:+CMSG: Start LoRaWAN transaction
INFO:RHF3M076:RECV:+CMSG: TX "338"
INFO:RHF3M076:RECV:+CMSG: Wait ACK
INFO:RHF3M076:RECV:+CMSG: ACK Received
INFO:RHF3M076:RECV:+CMSG: RXWIN1, RSSI -128, SNR -11.25
INFO:RHF3M076:RECV:+CMSG: Done
（以下同様のログ）
```

ログを確認する限りでは問題なく送信できているようですので、S3バケットにデータが送られているか確認します。

![s3.png](https://qiita-image-store.s3.amazonaws.com/0/94476/49bec483-6c0c-754b-c3d8-5869657cde9e.png)

10分毎にデータが作成されているのが確認できます。

さらに保存されているファイルの中身も確認してみます。

```
{"Time":"2017-11-20T12:09:02.876+01:00","DevEUI":"47XXXXXXXXXXXXXX","FPort":"8","FCntUp":"632","ADRbit":"1","MType":"4","FCntDn":"666","payload_hex":"363132","mic_hex":"04f92577","Lrcid":"00000201","LrrRSSI":"-111.000000","LrrSNR":"-7.000000","SpFact":"10","SubBand":"G0","Channel":"LC4","DevLrrCnt":"2","Lrrid":"65XXXXXX","Late":"0","LrrLAT":"33.590340","LrrLON":"130.401535","Lrrs":{"Lrr":[{"Lrrid":"65XXXXXX","Chain":"0","LrrRSSI":"-111.000000","LrrSNR":"-7.000000","LrrESP":"-118.790100"},{"Lrrid":"65XXXXXX","Chain":"0","LrrRSSI":"-117.000000","LrrSNR":"-10.500000","LrrESP":"-127.870773"}]},"CustomerID":"100008500","CustomerData":{"alr":{"pro":"LORA/Generic","ver":"1"}},"ModelCfg":"0","DevAddr":"01XXXXXX"}
```

プラットフォームから送られたデータが、JSON形式で保存されていますので完璧です。

#自動起動の設定
OSブート時にセンサースクリプトが自動起動されるよう、systemdに登録します。

まずはファイル`/etc/systemd/system/moisture.service`を作成します。

```ini:moisture.service
[Unit]
Description = Moisture Sensor

[Service]
ExecStart=/home/pi/Projects/LoRaWAN/moisture.py
Restart=always
Type=simple

[Install]
WantedBy=multi-user.target
```

`moisture`サービスの自動起動を有効化し、手動で起動します。

```
$ sudo systemctl enable moisture
$ sudo systemctl start moisture
```

#次回
今回はS3バケットにデータを蓄積しましたが、次回は`Firehose`の設定を変更し、データ加工後に`Amazon Redshift`に流し込むようにしたいと思います。