10
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

MongoDB Realmで、超初心者が無料でAndroidアプリのバックエンド処理を作ってみた

Last updated at Posted at 2020-08-15

はじめに

この記事は、
こちらの記事のIoTセンサデータを
モバイル連携クラウドサービス「MongoDB Realm」を通じて、スマホアプリとデータ連携させた記事となります
summary.png

スマホアプリ本体の作成に関しては、こちらで記事にしております

MongoDB Realmの利用法については、こちらの記事の内容を応用しております

まずは上の3つの記事をご覧頂いてから、本記事を見て頂けますと幸いです。

スマホアプリとバックエンド処理

詳しくは上の記事に記載しましたが、
サーバ連携したスマホアプリの作成には、

・オンプレサーバを立てて
・DB作成して
・API公開して
・Androidアプリのバックエンド処理を作成して
・AndroidアプリのUIを作成

というように、
バックエンド側に多大な開発コストが掛かることが多いそうです。
このような背景の中、バックエンド開発工数削減を目的としたクラウドサービスが増えています。

MongoDB Realmとは?

上記のようなクラウドサービスに参入すべく、MongoDB社がモバイル向けDB「Realm」を買収してで作られたサービスが、
MongoDB Realm
です。
mongodb_realm02.png
なんとこのサービス、無料枠でデータ取得処理をカンタンに実装できるだけでなく、
データのリアルタイム同期まで出来てしまいます。

今回は、上記MongoDB Realmを使用して、IoTアプリのバックエンド処理、
具体的には

A. Raspberry Piで取得したセンサデータをクラウドDBにアップロードする
B. 上記データをAndroidアプリ(自作クラスのインスタンス)と同期してスマホ表示する

という処理を実装してみます。

必要なもの

・各種温湿度センサ (対応しているセンサはこちら参照)
・Raspberry Pi (今回はRaspberry Pi3 ModelBを使用)
・ラズパイ内のPython実行環境 (今回はプリセットのPython3.7.3使用)
・開発用PC(今回はWindows10を使用)
・Android Studio (Androidアプリ開発環境)
・MongoDB Atlasのアカウント (無料で作成できます)
(・Androidスマホ(動作確認用))

手順

下記のような手順で、目的の処理を実装します。

  1. MongoDB Atlasの初期設定とサーバDBの作成
  2. RaspberryPiからサーバDBへのデータアップロード
  3. MongoDB Realmのサーバ側初期設定
  4. Androidアプリ作成

1,2が前章のA (RaspberryPi → クラウドDBへのアップロード)
3,4が前章のB (クラウドDB → Androidアプリ内とのデータ同期)
に相当します

1. MongoDB Atlasの初期設定とサーバDBの作成

サーバ側DBをクラウドサービスであるMongoDB Atlasに作成します

MongoDB Atlasの構造について

Organization > Project > Cluster > DB > Collecion
という構造になっています。
DBが通常のRDBMSのDBに、CollectionがRDBMSのテーブルに相当します。
ClusterはMongoDB独自の構造で、こちらを見る限り、分散処理の効率を上げるためのカテゴリだそうです。
Organization、ProjectはクラウドのAtlas独自の構造です。詳細は不明ですが、チームやプロジェクト毎の管理を前提としたカテゴリのようです。

MongoDB Atlasへの登録

MongoDB Atlasのトップページにアクセスし、「Start free」を押します
01_mongoatlastop.png

各種情報を入力し、登録します
02_register.png

クラスタの作成、ユーザの作成、ホワイトリストの設定

こちらをご参照いただければと思います。

なお、Realmを使用してスマホアプリと連携したい場合、MongoDBのバージョンを4.4とする必要がありますが、
2020/8現在、バージョン4.4を指定できるのは、プロバイダにAWSのN. Virginiaを指定したときのみなので、ご注意下さい。
私は下記のクラスタ設定としました。
05_selectplan.png

センサデータアップロード用DBおよびCollectionの作成

後でプログラムから作成することも可能ですが、誤実行を防ぐために手動で作成します。

クラスタ表示画面で「COLLECTIONS」を押します
11_selectcollection.png

「Add My Own Data」をクリックします
12_addmyowndata.png
DB名(好きな名前)とCollection(テーブルに相当)名"sensors"を入力し、「Create」を押します
13_createdb.png
入力したDBおよび"sensors"コレクションが生成されていれば成功です
14_confirmcollection.png

センサ一覧コレクションの作成

使用するセンサの一覧も、コレクションとして管理します。

DB名の横に出てくる+を押します
16_create_sensor_list_collection1.png

コレクション名に"sensor_lists"と入力します
17_create_sensor_list_collection2.png

手作業でドキュメントを追加し、使用するすべてのセンサの数だけ情報を入力します
18_create_sensor_list_collection3.png

各フィールドの意味は下記となります
no:連番を付ける
sensorname:センサ名
place:設置場所(indoor, outdoor, kitchenの3種類を想定)
temperature:温度情報の有無(true or false)
humidity:湿度情報の有無(true or false)
aircon:エアコンOn-Off情報の有無(Nature Remoのみtrueになる想定)
power:消費電力情報の有無(Nature Remoのみtrueになる想定)
_partition:あとでMongoDB Realmでの同期に使用(固定値"Project HomeIoT")

DBアクセス方法の確認

次節に備え、各種プログラム言語からDBにアクセスする方法を確認します

クラスタ表示画面で「CONNECT」を押します
08_connect.png

「Connect your application」を押します
09_connectapp.png
下図のように、言語を選択してアクセス方法を確認します
10_dbaccessfromapp.png
上記アクセス方法はあとで使うので、pythonでの内容を控えておいてください

2. RaspberryPiからサーバDBへのデータアップロード

こちらの記事をベースにします
まずは上記記事の内容を実装頂ければと思います

2-1. RaspberryPi内スクリプトの書き換え

今回はMongoDB Atlasへのアップロード処理を加えるために、
config.ini
DeviceList.csv
remo.py
sensors_to_spreadsheet.py
を、下記のように書き換えます。

config.ini

DB関係の設定を追加します

config.ini
[Path]
CSVOutput = CSV出力先を指定
LogOutput = ログ出力先を指定

[Process]
DBUploadRetry = 2

[DB]
UserName = MongoDB Atlasのユーザ名をここに記載(ダブルクオーテーションは不要)
ClusterName = MongoDB Atlasのクラスタ名をここに記載(ダブルクオーテーションは不要)
DBName = MongoDB AtlasのDB名をここに記載(ダブルクオーテーションは不要)
TableName = MongoDB Atlasのコレクション名をここに記載(ダブルクオーテーションは不要)

DeviceList.csv

No列を追加します(使用するデバイスに合わせて適宜記載を変えてください)

DeviceList.csv
No,DeviceName,SensorType,MacAddress,Timeout,Retry,Offset_Temp,Offset_Humid,API_URL,Token
1,SwitchBot_Thermo1,SwitchBot_Thermo,[SwitchBotのMacアドレス],4,3,0,0,,
2,Inkbird_IBSTH1_Mini1,Inkbird_IBSTH1mini,[IBS-TH1 miniのMacアドレス],0,2,0,0,,
3,Inkbird_IBSTH1_1,Inkbird_IBSTH1,[IBS-TH1のMacアドレス],0,2,0,0,,
4,Remo1,Nature_Remo,,0,2,0,0,https://api.nature.global/,[Nature Remoのアクセストークン]
5,Omron_USB1,Omron_USB_EP,[Omron USB型のMacアドレス],0,2,0,0,,
6,Omron_BAG1,Omron_BAG_EP,[Omron BAG型のMacアドレス],3,2,0,0,,

remo.py

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_power_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._decodeAirconPowerData(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のデータ
            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 _decodeAirconPowerData(self, rjson):
        Value = {}
        for appliance in rjson:
            #エアコン
            if appliance['type'] == 'AC':
                Value['TempSetting'] = appliance['settings']['temp']
                Value['Mode'] = appliance['settings']['mode']
                Value['AirVolume'] = appliance['settings']['vol']
                Value['AirDirection'] = appliance['settings']['dir']
                Value['Power'] = appliance['settings']['button']
            #スマートメータの電力データ
            elif appliance['type'] == 'EL_SMART_METER':
                for meterValue in appliance['smart_meter']['echonetlite_properties']:
                    if meterValue['name'] == 'normal_direction_cumulative_electric_energy':
                        Value['CumulativeEnergy'] = float(meterValue['val'])/100
                    elif meterValue['name'] == 'measured_instantaneous':
                        Value['Watt'] = int(meterValue['val'])        
        #値を取得できていないとき、Noneとする
        if len(Value) == 0:
            Value = None
        return Value

sensors_to_spreadsheet.py

メイン処理部分とoutput_mongodb_atlasメソッドに、MongoDB Atlasへのアップロード処理を追加します。

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 mesh import GetMeshFromSpreadsheet
from datetime import datetime, timedelta
import os
import csv
import configparser
import pandas as pd
import requests
import logging
import subprocess
import pymongo
from pit import Pit

#グローバル変数
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': masterdate,
            'Date': datetime.today(),
            'Temperature': scanner.delegate.sensorValue['Temperature'],
            'Humidity': scanner.delegate.sensorValue['Humidity'],
            'Light': scanner.delegate.sensorValue['Light'],
            'UV': scanner.delegate.sensorValue['UV'],
            'Pressure': scanner.delegate.sensorValue['Pressure'],
            'Noise': scanner.delegate.sensorValue['Noise'],
            'BatteryVoltage': 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': masterdate,
            'Date': datetime.today(),
            'Temperature': sensorValue['Temperature'],
            'Humidity': sensorValue['Humidity'],
            'Light': sensorValue['Light'],
            'Pressure': sensorValue['Pressure'],
            'Noise': sensorValue['Noise'],
            'eTVOC': sensorValue['eTVOC'],
            'eCO2': 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': masterdate,
            'Date': datetime.today(),
            'Temperature': sensorValue['Temperature'],
            'Humidity': 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': masterdate,
            'Date': datetime.today(),
            'Temperature': scanner.delegate.sensorValue['Temperature'],
            'Humidity': float(scanner.delegate.sensorValue['Humidity']),
            'BatteryVoltage': 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:
            airconPowerValue = GetRemoData().get_aircon_power_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': masterdate,
            'Date': datetime.today(),
            'Temperature': float(sensorValue['Temperature']),
            'Humidity': float(sensorValue['Humidity']),
            'Light': sensorValue['Light'],
            'Human_last': sensorValue['Human_last'],
            'HumanMotion': GetRemoData().calc_human_motion(sensorValue['Human_last'], f'{csvpath}/{device.DeviceName}')
        }
        #エアコン&電力データ
        if airconPowerValue is not None:
            data['TempSetting'] = int(airconPowerValue['TempSetting'])
            data['AirconMode'] = airconPowerValue['Mode']
            data['AirVolume'] = airconPowerValue['AirVolume']
            data['AirDirection'] = airconPowerValue['AirDirection']
            data['AirconPower'] = airconPowerValue['Power']
            if data['AirconPower'] == "":
                data['AirconPower'] = 'power-on_maybe'
            #電力
            if 'CumulativeEnergy' in airconPowerValue:
                data['CumulativeEnergy'] = float(airconPowerValue['CumulativeEnergy'])
            if 'Watt' in airconPowerValue:
                data['Watt'] = int(airconPowerValue['Watt'])
        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') as f:
            writer = csv.DictWriter(f, data.keys())
            writer.writeheader()
            writer.writerow(data)
    #出力ファイル存在するとき、1行追加
    else:
        with open(outpath, 'a') as f:
            writer = csv.DictWriter(f, data.keys())
            writer.writerow(data)

######MongoDB Atlasにアップロードする処理######
def output_mongodb_atlas(all_values_dict, user_name, cluster_name, db_name, collection_name, retry):
    passwd = ****#適宜パスワード隠蔽処理を作成してください
    for i in range(retry):
        try:
            client = pymongo.MongoClient(f"mongodb+srv://{user_name}:{passwd}@{cluster_name}.jipvx.mongodb.net/{db_name}?retryWrites=true&w=majority")
            db = client[db_name]
            collection = db[collection_name]
            result = collection.insert_one(all_values_dict)
        #エラー出たらログ出力
        except:
            if i == retry:
                logging.error(f'cannot upload to DB [loop{str(i)}, date{str(masterdate)}]')
            else:
                logging.warning(f'retry to upload to DB [loop{str(i)}, date{str(masterdate)}]')
            continue
        else:
            break

######Googleスプレッドシートにアップロードする処理######
def output_spreadsheet(all_values_dict_str):
    #APIのURL
    url = 'GAS APIのURLをここに記載'
    #APIにデータをPOST
    response = requests.post(url, json=all_values_dict_str)
    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
    #上記dictの文字列バージョン(GAS Post用、datetime型がJSON化できないため)
    all_values_dict_str = None

    #データ取得開始時刻
    scan_start_date = datetime.today()

    ######デバイスごとにデータ取得######
    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'])
        #mesh
        elif device.SensorType == 'Sony_MeshHuman':
            data = getdata_mesh_human(device)
        #上記以外
        else:
            data = None        

        #データが存在するとき、全データ保持用Dictに追加し、CSV出力
        if data is not None:
            #all_values_dictがNoneのとき、新たに辞書を作成
            if all_values_dict is None:
                #all_values_dictを作成(最初なのでDate_MasterとDate_ScanStartも追加)
                all_values_dict = {'Date_Master':data['Date_Master'], 'Date_ScanStart':scan_start_date}
                all_values_dict.update(dict([('no'+format(device.No,'02d')+'_'+k, v) for k,v in data.items() if k != 'Date_Master']))
                #dataを文字列変換してall_values_dict_strを作成(最初なのでDate_ScanStartを追加)
                data_str = dict([(k, str(v)) for k,v in data.items()])
                data_str['Date_ScanStart'] = str(scan_start_date)
                all_values_dict_str = {data_str['DeviceName']: data_str}
            #all_values_dictがNoneでないとき、既存の辞書に追加
            else:
                #all_values_dictに追加(最初でないのでDate_Masterは除外)
                all_values_dict.update(dict([('no'+format(device.No,'02d')+'_'+k, v) for k,v in data.items() if k != 'Date_Master']))
                #all_values_dict_strに追加
                data_str = dict([(k, str(v)) for k,v in data.items()])
                all_values_dict_str[data_str['DeviceName']] = data_str

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

    ######MongoDB Atlasにアップロードする処理######
    all_values_dict['_partition'] = 'Project HomeIoT'#あとでMongoDB Realmの同期に使用するフィールド
    output_mongodb_atlas(all_values_dict, cfg['DB']['UserName'], cfg['DB']['ClusterName'], cfg['DB']['DBName'], cfg['DB']['TableName'], int(cfg['Process']['DBUploadRetry']))

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

    #処理終了をログ出力
    logging.info(f'[masterdate{str(masterdate)} startdate{str(startdate)} enddate{str(datetime.today())} success{str(success_num)}/{str(sensor_num)}]')

sensors_to_spreadsheet.py内の
output_mongodb_atlas()
メソッドで、MongoDB Atlasにドキュメントデータ「all_values_dict」をPostしています。

上記output_mongodb_atlasメソッド内の処理と、前章の最後で控えたMongoDB Atlasへのpythonからのアクセス内容が一致していることを確認してください

2-2. DBへのアップロード動作確認

cronでRaspberryPi内のスクリプトを定期実行し、下図のようにクラウド画面上でコレクションへのアップロードが確認できれば成功です
15_confirm_post.png

3. MongoDB Realmのサーバ側初期設定

サーバ・クライアント連携システムであるMongoDB Realmの、サーバ側設定を実施します。
こちらを参考にさせて頂きました

3-1. Realmアプリケーションの作成

MongoDB Atlasのクラスタ設定画面から、「Realm」タブをクリックします。
1_select_realm.png!

今回の用途に合わせ、「Mobile」「Android」「No, I'm starting from scratch(新規作成)」を選択します
2_start_new_realm_app.png

アプリケーション名と、連携するMongoDB Atlasのクラスタを指定します。
※今回は「HomeIoTAppと名前を付けました」
3_cretate_realm_app.png

後で使うので、アプリケーションIDを控えておきます
04_copy_appid.png

3-2. ルールの作成

ルール作成対象のMongoDB Atlasコレクションを選択します
05_configure_collection.png

スキーマの作成

下記手順でスキーマを作成します

Schemaタブを開き、「GENERATE SCHEMA」をクリック
06_generate_schema1.png

下記手順で、コレクションの中身からスキーマを自動生成してくれます
07_generate_schema2.png

bsonTypeおよび必須フィールドを追記した上で、生成されたスキーマが想定と一致しているか確認し、SAVEします
※"title"は後でKotlinのデータモデルクラス名と一致させる必要があるので、複数形を付けないPascalCase命名規則推奨
08_generate_schema3.png

同様の手順で、sensor_listsコレクションのスキーマも作成します
※"title"を"SensorList"に変更するのを忘れないでください
19_schema_sensor_list.png

sensor_listsコレクションのスキーマ
{
  "title": "SensorList",
  "bsonType": "object",
  "required": ["_id"],
  "properties": {
    "_id": {
      "bsonType": "objectId"
    },
    "_partition": {
      "bsonType": "string"
    },
    "aircon": {
      "bsonType": "bool"
    },
    "humidity": {
      "bsonType": "bool"
    },
    "no": {
      "bsonType": "int"
    },
    "place": {
      "bsonType": "string"
    },
    "power": {
      "bsonType": "bool"
    },
    "sensorname": {
      "bsonType": "string"
    },
    "temperature": {
      "bsonType": "bool"
    }
  }
}

ユーザ権限の設定

Androidアプリ側からの、Email + パスワードによる認証を許可します。
Usersタブ → Providersタブをクリックします
14_configure_user_authentication1.png
Provider EnabledをONに
User Confirmation MethodをAutomatically confirm usersに
Password Reset MethodをRun a password reset functionに
Functionを、新たに作成したresetFuncに設定する
15_configure_user_authentication2.png

同期の設定

下図のように、同期を設定します
21_add_sync.png

変更をデプロイ

ここまでで設定に一区切りついたので、変更をアプリケーションに反映(デプロイ)させます。
画面の上の方に出てくるREVIEW&DEPLOYをクリックして、変更内容を確認したうえでDEPLOYをクリックします。
18_deploy_change.png
以後も設定に変更を加えた際は、適宜デプロイします

4. Androidアプリの作成

MongoDB Realmによりサーバとデータ同期可能なAndoroidアプリを作成します。
Android Studioを使用します。
基本には公式ドキュメントを参考にしました

4-1. プロジェクトの作成

Android Studioを開き、「Start a new Android Studio Project」をクリックします。
出てきたテンプレート選択画面で、「Empty Activity」を選択します。
30_select_template.png

好きなプロジェクト名、パッケージ名を記入し、
言語:Kotlin、Minimum SDK:API21を選択します。
31_configure_project.png

4-2. Realmプラグインのインストール

必要なプラグインをインストールします

Project全体へのインストール

左のタブから、2つあるBuild.gradleのうちProjectの方に以下の記述を追記し、プロジェクト全体にrealmプラグインをインストールします。
(2ヵ所のmavenはベータ版用の処理との記載なので、今後なくなるかもしれません)
32_install_project_gradle.png

build.gradle(Project)
buildscript {
      :
    repositories {
          :
        maven {
            url 'http://oss.jfrog.org/artifactory/oss-snapshot-local'
        }
    }
    dependencies {
          :
        classpath "io.realm:realm-gradle-plugin:10.0.0-BETA.5"
    }
allprojects {
    repositories {
          :
        maven {
            url 'http://oss.jfrog.org/artifactory/oss-snapshot-local'
        }
    }
}
  :

アプリケーションへのインストール

左のタブから、2つあるBuild.gradleのうちModule: appの方に以下の記述を追記し、アプリケーションにrealmプラグインをインストールします。
※上で控えたアプリケーションIDが必要になります
※kotlin-kaptはrealm-androidが依存するモジュールなので、後者より上に記載する必要あり
33_install_app_gradle.png

build.gradle(Module:app)
  :
apply plugin: 'kotlin-kapt'
apply plugin: 'realm-android'
  :
android {
      :
    buildTypes {
        def appId = "上で控えたアプリケーションID"  // Replace with proper Application ID
        debug {
            buildConfigField "String", "MONGODB_REALM_APP_ID", "\"${appId}\""
        }
        release {
            buildConfigField "String", "MONGODB_REALM_APP_ID", "\"${appId}\""
            minifyEnabled false
            signingConfig signingConfigs.debug
        }
    }

    compileOptions {
        sourceCompatibility 1.8
        targetCompatibility 1.8
    }
}

realm {
    syncEnabled = true
}

dependencies {
      :
    implementation 'com.google.android.material:material:1.2.0'
      :
    implementation "io.realm:android-adapters:4.0.0"
    implementation "androidx.recyclerview:recyclerview:1.1.0"
      :
}

ここで一度File → Close Projectでプロジェクトを開きなおしたのち、
プログラムを実行して正常にアプリが起動するか確認します(各種ライブラリがエラーを出さないか確認)
34_run_emulator.png
私の場合起動までに1分以上かかったので、気長に待ちましょう。

4-3. データモデルの作成

サーバDBのコレクションからデータを受け取るためのKotlinクラス(データモデル)を作成します。
具体的には下記の2つのクラスを作成します

Sensor.kt:センサデータアップロード用コレクションに対応
SensorList.kt:sensor_listsコレクションに対応

modelパッケージの作成

下記の手順で、モデルを保持するパッケージ(フォルダ)を作成します。

javaフォルダ内のパッケージフォルダを右クリックし、New → Package
35.png

パッケージに"model"という名前を付けます
36.png

Sensor.kt

作成したパッケージ名を右クリックし、New → Kotlin File/Class
37.png
Classを選択し、"Sensor"と名前を付けてEnter
38.png

MongoDB Realmのクラウド画面より下記操作で、Kotlinでのデータモデル記述法を確認する
39.png

上記操作で確認した内容をコピペし、SensorData.ktに貼り付ける。
下記のような内容となるはずです。

Sensor.kt
package com.mongodb.homeiotviewer.model

import io.realm.RealmObject;
import io.realm.annotations.PrimaryKey
import java.util.Date;
import org.bson.types.ObjectId;

open class Sensor (
    @PrimaryKey var _id: ObjectId = ObjectId(),
    var Date_Master: Date = Date(),
    var Date_ScanStart: Date? = null,
    var _partition: String? = null,
    var no01_Date: Date? = null,
    var no01_DeviceName: String? = null,
    var no01_Humidity: Double? = null,
    var no01_Temperature: Double? = null,
      :
    中略
      :
    var no07_Humidity: Double? = null,
    var no07_Temperature: Double? = null,
    ): RealmObject() {}

SensorList.kt

上記と同様の方法で、modelフォルダ内にSensorList.ktを作成する

SensorList.kt
package com.mongodb.homeiotviewer.model

import io.realm.RealmObject;
import io.realm.annotations.PrimaryKey
import org.bson.types.ObjectId;

open class SensorList (
    @PrimaryKey var _id: ObjectId = ObjectId(),
    var _partition: String? = null,
    var aircon: Boolean? = null,
    var humidity: Boolean? = null,
    var no: Long? = null,
    var place: String? = null,
    var power: Boolean? = null,
    var sensorname: String? = null,
    var temperature: Boolean? = null
): RealmObject() {}

4-4. グローバルインスタンス生成用クラス作成

アプリ全体で共有する、グローバルインスタンスを定義します。
具体的には下記となります

app: Realmアプリケーションのインスタンス (Appクラス)

クラスの作成

上記グローバルインスタンスを生成するクラスを、下記手順で作成します。
35_make_global_realmapp_instance.png

InitRealm.kt
package com.mongodb.homeiotviewer

import android.app.Application
import android.util.Log

import io.realm.Realm
import io.realm.log.LogLevel
import io.realm.log.RealmLog
import io.realm.mongodb.App
import io.realm.mongodb.AppConfiguration

//Realmアプリケーションのインスタンス(グローバルインスタンスとして、アプリケーション全体で共有する)
lateinit var app: App

// global Kotlin extension that resolves to the short version
// of the name of the current class. Used for labelling logs.
inline fun <reified T> T.TAG(): String = T::class.java.simpleName

/*
* InitRealm: Sets up the Realm App and enables Realm-specific logging in debug mode.
*/
class InitRealm : Application() {

    override fun onCreate() {
        super.onCreate()
        //Realmライブラリの初期化
        Realm.init(this)
        //Realmアプリケーションにアクセスしインスタンス化
        app = App(
            AppConfiguration.Builder(BuildConfig.MONGODB_REALM_APP_ID)
                .build())

        // デバッグモード時に追加ロギングを有効に
        if (BuildConfig.DEBUG) {
            RealmLog.setLevel(LogLevel.ALL)
        }

        Log.v(TAG(), "Initialized the Realm App configuration for: ${app.configuration.appId}")
    }
}

マニフェストに登録

上記クラスをマニフェストに登録します。これによりアプリ起動時に上記インスタンスが初期化されるようです。
40_add_globalinstance_class_to_manifest.png
AndroidManifest.xmlに、下記のように追記します

AndroidManifest.xml
    <application
          :
        android:name="com.mongodb.homeiotviewer.InitRealm">
          :

4-5. ログイン画面の作成

MongoDB Realmのユーザアカウント作成orログインする画面を作成します。

ログイン画面アクティビティの作成

下記操作で、ログイン画面のアクティビティ(Androidアプリにおける画面GUIに相当する部品)を作成します
javaフォルダ内のパッケージフォルダを右クリックし、New → Activity → Empty Activity
37_make_login_activity1.png
名前を"LoginActivity"に変え、Finish
38_make_login_activity2.png

ログイン画面のレイアウト作成

下記操作で、画面レイアウトactivity_login.xmlを編集します

先ほどの操作でapp → res → layoutに生成したactivity_login.xmlを開く
39_edit_login_activity_layout.png
activity_login.xmlを下記のように編集

activity_login.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LoginActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:paddingLeft="24dp"
        android:paddingTop="12dp"
        android:paddingRight="24dp">

        <com.google.android.material.textfield.TextInputLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp">

            <EditText
                android:id="@+id/input_username"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/username"
                android:inputType="text" />
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp">

            <EditText
                android:id="@+id/input_password"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/password"
                android:inputType="textPassword" />
        </com.google.android.material.textfield.TextInputLayout>

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/button_login"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:layout_marginBottom="12dp"
            android:padding="12dp"
            android:text="@string/login" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/button_create"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="24dp"
            android:padding="12dp"
            android:text="@string/create_account" />
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

上記だけだと変数指定部分でエラーが出るので、res → values
→ strings.xmlに下記内容を追記します
40_edit_stringsxml.png

strings.xml
<resources>
    <string name="app_name">RealmTutorial</string>//ここはプロジェクト名が元々記載されている
    <string name="username">Email</string>
    <string name="password">Password</string>
    <string name="create_account">Create account</string>
    <string name="login">Login</string>
    <string name="more">\u22EE</string>
    <string name="new_task">Create new task</string>
    <string name="logout">Log Out</string>
</resources>

Designタブで確認すると、下図のようなレイアウトになっているはずです。
41_confirm_login_activity_layout.png

MainActivityからログイン画面への遷移作成

ログイン中ユーザが存在しないときはログイン画面に遷移するよう、
java → パッケージ名のフォルダ内にあるMainActivity.ktを下記のように書き換えます

MainActivity.kt
package com.mongodb.homeiotviewer//パッケージ名に合わせて修正

import android.content.Intent
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import io.realm.mongodb.User

class MainActivity : AppCompatActivity() {
    private var user: User? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onStart() {
        super.onStart()
        //ログイン中ユーザの取得
        try {
            user = app.currentUser()
        } catch (e: IllegalStateException) {
            Log.w(TAG(), e)
        }
        //ログイン中ユーザが存在しない時、ログイン画面を表示する
        if (user == null) {
            // if no user is currently logged in, start the login activity so the user can authenticate
            startActivity(Intent(this, LoginActivity::class.java))
        }
    }
}

ログイン処理クラスの作成

ログイン画面でボタンを押した際の処理を、下記のように実装します。

java → パッケージ名のフォルダ内にあるLoginActivity.ktを下記のように書き換えます

LoginActivity.kt
package com.mongodb.homeiotviewer

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import io.realm.mongodb.Credentials

class LoginActivity : AppCompatActivity() {
    //各種入力フォームを保持するインスタンス
    private lateinit var username: EditText//ユーザ名(Eメールアドレス)入力用テキストボックス
    private lateinit var password: EditText//パスワード入力用テキストボックス
    private lateinit var loginButton: Button//ログインボタン
    private lateinit var createUserButton: Button//新規ユーザ作成ボタン

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //入力フォームインスタンスの生成
        setContentView(R.layout.activity_login)
        username = findViewById(R.id.input_username)
        password = findViewById(R.id.input_password)
        loginButton = findViewById(R.id.button_login)
        createUserButton = findViewById(R.id.button_create)
        //ボタンを押したときの処理
        loginButton.setOnClickListener { login(false) }//ログインホタン
        createUserButton.setOnClickListener { login(true) }//新規ユーザ作成ボタン
    }

    override fun onBackPressed() {
        //戻るボタンでメイン画面に戻れないようにする(メイン画面に戻るにはログインが必須)
        // Disable going back to the MainActivity
        moveTaskToBack(true)
    }

    private fun onLoginSuccess() {
        //ログインに成功したら、メイン画面に戻る
        // successful login ends this activity, bringing the user back to the main activity
        finish()
    }

    private fun onLoginFailed(errorMsg: String) {
        //ログインに失敗したら、ログに書き込んだ上でメッセージ表示
        Log.e(TAG(), errorMsg)
        Toast.makeText(baseContext, errorMsg, Toast.LENGTH_LONG).show()
    }

    private fun validateCredentials(): Boolean = when {
        //ユーザ名とパスワードが空欄でないことを確認
        // zero-length usernames and passwords are not valid (or secure), so prevent users from creating accounts with those client-side.
        username.text.toString().isEmpty() -> false
        password.text.toString().isEmpty() -> false
        else -> true
    }

    /**
     * ログインボタンを押したときの処理
     * @param[createUser]:trueなら新規ユーザ作成、falseなら通常のログイン
     */
    // handle user authentication (login) and account creation
    private fun login(createUser: Boolean) {
        if (!validateCredentials()) {
            onLoginFailed("Invalid username or password")
            return
        }

        //処理中はボタンを押せないようにする
        // while this operation completes, disable the buttons to login or create a new account
        createUserButton.isEnabled = false
        loginButton.isEnabled = false

        val username = this.username.text.toString()
        val password = this.password.text.toString()

        if (createUser) {//新規ユーザ作成のとき
            // ユーザ名(E-mailアドレス)+パスワードでユーザ作成実行
            // register a user using the Realm App we created in the TaskTracker class
            app.emailPasswordAuth.registerUserAsync(username, password) {
                // re-enable the buttons after user registration completes
                createUserButton.isEnabled = true
                loginButton.isEnabled = true
                if (!it.isSuccess) {//ユーザ作成失敗時は、メッセージを表示
                    onLoginFailed("Could not register user.")
                    Log.e(TAG(), "Error: ${it.error}")
                } else {//成功時は、そのまま通常ログイン
                    Log.i(TAG(), "Successfully registered user.")
                    // when the account has been created successfully, log in to the account
                    login(false)
                }
            }
        } else {//通常ログインのとき
            val creds = Credentials.emailPassword(username, password)
            app.loginAsync(creds) {
                // re-enable the buttons after
                loginButton.isEnabled = true
                createUserButton.isEnabled = true
                if (!it.isSuccess) {//ログイン失敗時は、メッセージを表示
                    onLoginFailed(it.error.message ?: "An error occurred.")
                } else {//成功時は、メイン画面に戻る
                    onLoginSuccess()
                }
            }
        }
    }
}

基本的な処理の流れは下記となります
・ログインボタン:ユーザ名&パスワード入力確認 → ログイン → 成功したらメイン画面に遷移
・新規ユーザ作成ボタン:ユーザ名&パスワード入力確認 → ユーザ作成 → 成功したらログインボタン処理に移る

4-6. ログアウト処理の作成

ログアウトメニューの追加

ログアウトメニューのレイアウトlogout_menu.xmlを、下記手順で追加します。

app → resを右クリックし、New → Android Resource Directoryを選択します
50_make_option_menu_layout1.png
Resource Typeを「menu」に変更し、OKを押します
51_make_option_menu_layout2.png
生成したapp → res → menuフォルダを右クリックし、New → Menu Resource Fileを選択します
52_make_option_menu_layout3.png
"logout_menu"と名前を付けてOKを押します
41_make_option_menu_layout4.png

生成したlogout_menu.xmlを、下記のように書き換えます

logout_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".CounterActivity">
    <item
        android:id="@+id/action_logout"
        android:orderInCategory="100"
        android:title="@string/logout"
        android:text="@string/logout"
        app:showAsAction="always"/>
</menu>

MainActivityにログアウト処理追加

作成したログアウトメニューをメインアクティビティに追加するため、
MainActivity.ktを下記のように書き換えます

MainActivity.kt
package com.mongodb.homeiotviewer

import android.content.Intent
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import io.realm.Realm
import io.realm.mongodb.User
import io.realm.mongodb.sync.SyncConfiguration

class MainActivity : AppCompatActivity() {
    private lateinit var realm: Realm//Realmデータベースのインスタンス
    private var user: User? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //Realmデータベースのインスタンス初期化
        realm = Realm.getDefaultInstance()
    }

    override fun onStart() {
        super.onStart()
        //ログイン中ユーザの取得
        try {
            user = app.currentUser()
        } catch (e: IllegalStateException) {
            Log.w(TAG(), e)
        }
        //ログイン中ユーザが存在しない時、ログイン画面を表示する
        if (user == null) {
            // if no user is currently logged in, start the login activity so the user can authenticate
            startActivity(Intent(this, LoginActivity::class.java))
        }
    }

    override fun onStop() {
        super.onStop()
        user.run {
            realm.close()
        }
    }

    //アクティビティ終了時の処理(realmインスタンスをClose)
    override fun onDestroy() {
        super.onDestroy()
        // if a user hasn't logged out when the activity exits, still need to explicitly close the realm
        realm.close()
    }

    //logoutメニューをMainActivity上に設置
    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.logout_menu, menu)
        return true
    }

    //logoutメニューを押したときの処理(ログアウト)
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.action_logout -> {
                user?.logOutAsync {
                    if (it.isSuccess) {
                        // always close the realm when finished interacting to free up resources
                        realm.close()
                        user = null
                        Log.v(TAG(), "user logged out")
                        startActivity(Intent(this, LoginActivity::class.java))
                    } else {
                        Log.e(TAG(), "log out failed! Error: ${it.error}")
                    }
                }
                true
            }
            else -> {
                super.onOptionsItemSelected(item)
            }
        }
    }
}

ログイン&ログアウト動作確認

この段階でエミュレータでソフトを実行し、
ログイン&ログアウト処理が正常にできることを確認しましょう
基本的にはこちらの記事と同じ方法でログイン&ログアウトできます

4-7. データの取得と表示

Realmの同期とデータ取得、および取得したデータを表示

表示用レイアウトの準備

デフォルトで"hello world"と表示しているTextViewを、Realmから取得したデータ表示に流用します。

activity_main.xmlに
android:id="@+id/query_test"
という記述を追加します(IDを記載してコードからアクセスできるようにする)

activitiy_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/query_test"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

同期およびクエリ処理の追加

MainActivity.ktに、を追加します
・前節で作成したTextViewをインスタンス化し、文字を追加できるようにする処理
・同期およびクエリでデータ取得する処理

TextView関係の処理は、下記となります

      :
    private lateinit var queryTestView: TextView//クエリ結果表示用のtextViewインスタンス
      :
    override fun onCreate(savedInstanceState: Bundle?) {
          :
        //クエリ結果表示用のtextViewインスタンス作成
        queryTestView = findViewById(R.id.query_test)

同期およびクエリによるデータ取得処理は、下記となります

      :
    override fun onStart() {
          :
        //ログイン中ユーザが存在しないとき
        if (user == null) {
              :
        }
        //ログイン中ユーザが存在するとき
        else {
            //MongoDB Realmとの同期設定
            val partitionValue: String = "Project HomeIoT"//
            val config = SyncConfiguration.Builder(user!!, "Project HomeIoT")
                .waitForInitialRemoteData()
                .build()
            //上記設定をデフォルトとして保存
            Realm.setDefaultConfiguration(config)
            //非同期バックグラウンド処理でMongoDB Realmと同期実行
            Realm.getInstanceAsync(config, object: Realm.Callback() {
                override fun onSuccess(realm: Realm) {
                    //同期したRealmインスタンスを親クラスMainActivityのインスタンスに設定
                    this@MainActivity.realm = realm
                    //クエリ操作用インスタンス作成
                    val listQuery = realm.where<SensorList>()
                    ////////以下の処理は、取得するデータ内容に応じて変える////////
                    val result = listQuery.sort("no").findAll()
                    var resultString: String = ""
                    for(device in result){
                        resultString += "${device.no},${device.sensorname},${device.place}\n"
                    }
                    //クエリ結果表示用のtextViewインスタンス
                    queryTestView.text = resultString
                }
            })
        }
    }

公式ドキュメントを見る限り、データ同期しつつ内容を取得・更新したい場合、
下記のコールバック処理の中にコードを記載する事が肝要となりそうです。
(FragmentへのAdapter処理等もこの中に記載)

    Realm.getInstanceAsync(config, object: Realm.Callback() {
        override fun onSuccess(realm: Realm) {
            //この中にデータの取得、更新関係の処理を全て記載
        }
    }

上記の変更を加えたMainActivity.ktは、下記のようになるはずです

MainActivity.kt
package com.mongodb.homeiotviewer

import android.content.Intent
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import com.mongodb.homeiotviewer.model.Sensor
import com.mongodb.homeiotviewer.model.SensorList
import io.realm.Realm
import io.realm.mongodb.User
import io.realm.mongodb.sync.SyncConfiguration
import io.realm.kotlin.where

class MainActivity : AppCompatActivity() {
    private lateinit var realm: Realm//Realmデータベースのインスタンス
    private var user: User? = null
    private lateinit var queryTestView: TextView//クエリ結果表示用のtextViewインスタンス

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //Realmデータベースのインスタンス初期化
        realm = Realm.getDefaultInstance()

        //クエリ結果表示用のtextViewインスタンス作成
        queryTestView = findViewById(R.id.query_test)
    }

    override fun onStart() {
        super.onStart()
        //ログイン中ユーザの取得
        try {
            user = app.currentUser()
        } catch (e: IllegalStateException) {
            Log.w(TAG(), e)
        }
        //ログイン中ユーザが存在しない時、ログイン画面を表示する
        if (user == null) {
            // if no user is currently logged in, start the login activity so the user can authenticate
            startActivity(Intent(this, LoginActivity::class.java))
        }
        //ログイン中ユーザが存在するとき
        else {
            //MongoDB Realmとの同期設定
            val partitionValue: String = "Project HomeIoT"//パーティションの名前
            val config = SyncConfiguration.Builder(user!!, "Project HomeIoT")
                .waitForInitialRemoteData()
                .build()
            //上記設定をデフォルトとして保存
            Realm.setDefaultConfiguration(config)
            //非同期バックグラウンド処理でMongoDB Realmと同期実行
            Realm.getInstanceAsync(config, object: Realm.Callback() {
                override fun onSuccess(realm: Realm) {
                    //同期したRealmインスタンスを親クラスMainActivityのインスタンスに設定
                    this@MainActivity.realm = realm
                    //クエリ操作用インスタンス作成
                    val listQuery = realm.where<SensorList>()//sensor_listsコレクション
                    val sensorQuery = realm.where<Sensor>()//sensorsコレクション
                    ////////以下の処理は、取得するデータ内容に応じて変える////////
                    val result = listQuery.sort("no").findAll()
                    var resultString: String = ""
                    for(device in result){
                        resultString += "${device.no},${device.sensorname},${device.place}\n"
                    }
                    //クエリ結果をtextViewに表示
                    queryTestView.text = resultString
                }
            })
        }
    }

    override fun onStop() {
        super.onStop()
        user.run {
            realm.close()
        }
    }

    //アクティビティ終了時の処理(realmインスタンスをClose)
    override fun onDestroy() {
        super.onDestroy()
        // if a user hasn't logged out when the activity exits, still need to explicitly close the realm
        realm.close()
    }

    //logoutメニューをMainActivity上に設置
    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.logout_menu, menu)
        return true
    }

    //logoutメニューを押したときの処理(ログアウト)
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.action_logout -> {
                user?.logOutAsync {
                    if (it.isSuccess) {
                        // always close the realm when finished interacting to free up resources
                        realm.close()
                        user = null
                        Log.v(TAG(), "user logged out")
                        startActivity(Intent(this, LoginActivity::class.java))
                    } else {
                        Log.e(TAG(), "log out failed! Error: ${it.error}")
                    }
                }
                true
            }
            else -> {
                super.onOptionsItemSelected(item)
            }
        }
    }
}

上記のコードを実行すると、下図のように
sensor_listコレクションを"no"の順でソートした、"device","sensorname","place"フィールド
が画面に表示されるはずです。
50.png
下記のクエリ部分の処理を書き換えることで、取得するデータ内容を変えることができます。
次章で何種類か試してみます。

    ////////以下の処理は、取得するデータ内容に応じて変える////////
    val result = listQuery.sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

5. クエリ操作

前章の最後で触れたクエリ処理内容を変更し、どのようなデータが取得できるか試してみます

ソート順を降順に

ソート引数にSort.DESCENDINGを指定

    val result = listQuery.sort("no", Sort.DESCENDING).findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

sort.png

フィルタ処理

一致

.equalTo()を使用
※不一致を表す.notEqualTo()もあります

    val result = listQuery.equalTo("place", "indoor").sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

equalto.png

不等号

.greaterThan()または.lessThan()を使用
※等号込みの.greaterThanOrEqualTo()、.lessThanOrEqualTo()もあります

    val result = listQuery.greaterThan("no", 4).sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

greater.png

範囲

.between()を使用

    val result = listQuery.between("no", 2, 6).sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

between.png

配列指定

.in()を使用

    val result = listQuery.`in`("place", arrayOf("kitchen","outdoor")).sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

in.png

文字列操作

.beginsWith()、.contains()、.endsWith()、.like()を使用
※likeのワイルドカード指定方法は公式ドキュメントを参照ください

    val result = listQuery.beginsWith("sensorname", "Omron").sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

beginswith.png

論理演算

.and()、.not()、.or()を使用

    val result = listQuery.beginsWith("sensorname", "Inkbird").and().equalTo("place","indoor").sort("no").findAll()
    var resultString: String = ""
    for(device in result){
        resultString += "${device.no},${device.sensorname},${device.place}\n"
    }

image.png

統計量

以降はsensorsコレクションのデータを使用します

平均、合計

.average()、.sum()を使用

    val result = sensorQuery.average("no01_Temperature")
    val resultString = result.toString()

average.png
※センサno.01の平均温度を表示しています

最大、最小

.max()、.min()を使用

    val result = sensorQuery.min("no01_Temperature")
    val resultString = result.toString()

min.png
※センサno.01の最低温度を表示しています

####個数
.count()を使用

    val result = sensorQuery.isNull("no03_Temperature").count()
    val resultString = result.toString()

nullcount.png
※isNull()と組み合わせて、"no03_Temperature"フィールドのnull数をカウントしています

1要素取得

最初の要素

.findFirst()を使用して、並び替えた最初の要素を取得できます

    val result = sensorQuery.sort("Date_Master").findFirst()
    val resultString = "${result?.Date_Master}\n" +
        "${result?.no01_Temperature}゜C\n"+
        "${result?.no01_Humidity}%\n"

first.png
※"Date_Master"順で最初の要素のNo01_TemperatureとNo01_Humidityを表示しています

最後の要素

Sort.DESCENDINGと組み合わせて、並び替えた最後の要素を取得できます

    val result = sensorQuery.sort("Date_Master", Sort.DESCENDING).findFirst()
    val resultString = "${result?.Date_Master}\n" +
        "${result?.no01_Temperature}゜C\n"+
        "${result?.no01_Humidity}%\n"

last.png
※"Date_Master"順で最初の要素のNo01_TemperatureとNo01_Humidityを表示しています

Read以外のクエリ

今回はRead関係のクエリのみ紹介しましたが、
Write、Update関係のクエリも存在します。
下記の公式ドキュメントをご参照ください
https://docs.mongodb.com/realm/android/

トラブルシューティング

私がハマったのは以下の部分です

「スキーマの"title"」と「データモデルクラス」の名称一致

私が一番ハマって時間を浪費してしまったのが、この部分です

MongoDB Realmでは、クラウド上のDBと、スマホアプリ上のKotlinデータモデルとの同期をとる際に、
クラウド上のDB: スキーマの"title"
Kotlinデータモデル: クラスの名称
の一致をもって、同期を実施しているようです。

ここで厄介なのが、両者の命名規則の違いです。
クラウド上のDB: DBのテーブル命名規則にのっとって、snake_case(小文字アンダーバー区切り)
Kotlinデータモデル: Kotlinのクラス命名規則にのっとって、PascalCase(単語の最初を大文字に)
が利用されるのが一般的です。

私は最初、例えばsensorlistコレクションでは

クラウド上のDB コレクション名: "sensor_lists" スキーマのtitle: "sensor_lists"
Kotlinデータモデル クラス名"SensorList"

と設定していましたが、これではスキーマのtitleとKotlinクラス名が一致していないため、
両者の同期が取れませんでした。
####対策
色々調べてみて、両者の名称の一致が同期の条件となることが分かったので、

クラウド上のDB コレクション名: "sensor_lists" スキーマのtitle: "SensorList"
Kotlinデータモデル クラス名"SensorList"

のように変更したところ、正常に同期できるようになりました。

型の変化による同期失敗

MongoDBには、Postした際に自動で型を判定してくれる便利な機能がありますが、
この機能より、スキーマを定義した際とは思いがけず型が変わってしまい、同期が失敗してしまうことがあります。

私の場合、スキーマ定義時には"no01_Temperature"フィールド(Nature Remoの気温)が
30.4 → MongoDBでdouble型
と判定されていたのが、あるタイミングで測定温度が整数となり
30 → MongoDBでint型
と判定されてしまい、型がスキーマと異なっているために同期に失敗していました

対策

Postする前に、Pythonでfloat型にキャストするように変更しました

before
'Temperature': sensorValue['Temperature'],
after
'Temperature': float(sensorValue['Temperature']),

また、このようなスキーマと異なるデータが存在するかどうかの確認は
MongoDB Realmクラウド画面のRulesタブ → 対象コレクションを選択 → Schemaタブ → VALIDATEボタン
で実施可能です。

さいごに

以上で、バックエンド処理の作成は完了しました。

一方で、スマホアプリとして実用的なものとするには、フロントエンド処理を実装してUIを作成する必要があります
フロントエンド部分は本記事の趣旨から外れるので、以下の記事に再び戻って解説させて頂きます
(IoTデータをグラフ表示するAndroidアプリを作成しました)

10
11
5

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
10
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?