はじめに
この記事は、
こちらの記事のIoTセンサデータを
モバイル連携クラウドサービス「MongoDB Realm」を通じて、スマホアプリとデータ連携させた記事となります
スマホアプリ本体の作成に関しては、こちらで記事にしております
MongoDB Realmの利用法については、こちらの記事の内容を応用しております
まずは上の3つの記事をご覧頂いてから、本記事を見て頂けますと幸いです。
スマホアプリとバックエンド処理
詳しくは上の記事に記載しましたが、
サーバ連携したスマホアプリの作成には、
・オンプレサーバを立てて
・DB作成して
・API公開して
・Androidアプリのバックエンド処理を作成して
・AndroidアプリのUIを作成
というように、
バックエンド側に多大な開発コストが掛かることが多いそうです。
このような背景の中、バックエンド開発工数削減を目的としたクラウドサービスが増えています。
MongoDB Realmとは?
上記のようなクラウドサービスに参入すべく、MongoDB社がモバイル向けDB「Realm」を買収してで作られたサービスが、
MongoDB Realm
です。
なんとこのサービス、無料枠でデータ取得処理をカンタンに実装できるだけでなく、
データのリアルタイム同期まで出来てしまいます。
今回は、上記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スマホ(動作確認用))
手順
下記のような手順で、目的の処理を実装します。
- MongoDB Atlasの初期設定とサーバDBの作成
- RaspberryPiからサーバDBへのデータアップロード
- MongoDB Realmのサーバ側初期設定
- 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」を押します
クラスタの作成、ユーザの作成、ホワイトリストの設定
こちらをご参照いただければと思います。
なお、Realmを使用してスマホアプリと連携したい場合、MongoDBのバージョンを4.4とする必要がありますが、
2020/8現在、バージョン4.4を指定できるのは、プロバイダにAWSのN. Virginiaを指定したときのみなので、ご注意下さい。
私は下記のクラスタ設定としました。
センサデータアップロード用DBおよびCollectionの作成
後でプログラムから作成することも可能ですが、誤実行を防ぐために手動で作成します。
「Add My Own Data」をクリックします
DB名(好きな名前)とCollection(テーブルに相当)名"sensors"を入力し、「Create」を押します
入力したDBおよび"sensors"コレクションが生成されていれば成功です
センサ一覧コレクションの作成
使用するセンサの一覧も、コレクションとして管理します。
手作業でドキュメントを追加し、使用するすべてのセンサの数だけ情報を入力します
各フィールドの意味は下記となります
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 your application」を押します
下図のように、言語を選択してアクセス方法を確認します
上記アクセス方法はあとで使うので、pythonでの内容を控えておいてください
2. RaspberryPiからサーバDBへのデータアップロード
こちらの記事をベースにします
まずは上記記事の内容を実装頂ければと思います
2-1. RaspberryPi内スクリプトの書き換え
今回はMongoDB Atlasへのアップロード処理を加えるために、
config.ini
DeviceList.csv
remo.py
sensors_to_spreadsheet.py
を、下記のように書き換えます。
config.ini
DB関係の設定を追加します
[Path]
CSVOutput = CSV出力先を指定
LogOutput = ログ出力先を指定
[Process]
DBUploadRetry = 2
[DB]
UserName = MongoDB Atlasのユーザ名をここに記載(ダブルクオーテーションは不要)
ClusterName = MongoDB Atlasのクラスタ名をここに記載(ダブルクオーテーションは不要)
DBName = MongoDB AtlasのDB名をここに記載(ダブルクオーテーションは不要)
TableName = MongoDB Atlasのコレクション名をここに記載(ダブルクオーテーションは不要)
DeviceList.csv
No列を追加します(使用するデバイスに合わせて適宜記載を変えてください)
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
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へのアップロード処理を追加します。
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内のスクリプトを定期実行し、下図のようにクラウド画面上でコレクションへのアップロードが確認できれば成功です
3. MongoDB Realmのサーバ側初期設定
サーバ・クライアント連携システムであるMongoDB Realmの、サーバ側設定を実施します。
こちらを参考にさせて頂きました
3-1. Realmアプリケーションの作成
MongoDB Atlasのクラスタ設定画面から、「Realm」タブをクリックします。
!
今回の用途に合わせ、「Mobile」「Android」「No, I'm starting from scratch(新規作成)」を選択します
アプリケーション名と、連携するMongoDB Atlasのクラスタを指定します。
※今回は「HomeIoTAppと名前を付けました」
3-2. ルールの作成
ルール作成対象のMongoDB Atlasコレクションを選択します
スキーマの作成
下記手順でスキーマを作成します
Schemaタブを開き、「GENERATE SCHEMA」をクリック
下記手順で、コレクションの中身からスキーマを自動生成してくれます
bsonTypeおよび必須フィールドを追記した上で、生成されたスキーマが想定と一致しているか確認し、SAVEします
※"title"は後でKotlinのデータモデルクラス名と一致させる必要があるので、複数形を付けないPascalCase命名規則推奨
同様の手順で、sensor_listsコレクションのスキーマも作成します
※"title"を"SensorList"に変更するのを忘れないでください
{
"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タブをクリックします
Provider EnabledをONに
User Confirmation MethodをAutomatically confirm usersに
Password Reset MethodをRun a password reset functionに
Functionを、新たに作成したresetFuncに設定する
同期の設定
変更をデプロイ
ここまでで設定に一区切りついたので、変更をアプリケーションに反映(デプロイ)させます。
画面の上の方に出てくるREVIEW&DEPLOYをクリックして、変更内容を確認したうえでDEPLOYをクリックします。
以後も設定に変更を加えた際は、適宜デプロイします
4. Androidアプリの作成
MongoDB Realmによりサーバとデータ同期可能なAndoroidアプリを作成します。
Android Studioを使用します。
基本には公式ドキュメントを参考にしました
4-1. プロジェクトの作成
Android Studioを開き、「Start a new Android Studio Project」をクリックします。
出てきたテンプレート選択画面で、「Empty Activity」を選択します。
好きなプロジェクト名、パッケージ名を記入し、
言語:Kotlin、Minimum SDK:API21を選択します。
4-2. Realmプラグインのインストール
必要なプラグインをインストールします
Project全体へのインストール
左のタブから、2つあるBuild.gradleのうちProjectの方に以下の記述を追記し、プロジェクト全体にrealmプラグインをインストールします。
(2ヵ所のmavenはベータ版用の処理との記載なので、今後なくなるかもしれません)
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が依存するモジュールなので、後者より上に記載する必要あり
:
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でプロジェクトを開きなおしたのち、
プログラムを実行して正常にアプリが起動するか確認します(各種ライブラリがエラーを出さないか確認)
私の場合起動までに1分以上かかったので、気長に待ちましょう。
4-3. データモデルの作成
サーバDBのコレクションからデータを受け取るためのKotlinクラス(データモデル)を作成します。
具体的には下記の2つのクラスを作成します
Sensor.kt:センサデータアップロード用コレクションに対応
SensorList.kt:sensor_listsコレクションに対応
modelパッケージの作成
下記の手順で、モデルを保持するパッケージ(フォルダ)を作成します。
javaフォルダ内のパッケージフォルダを右クリックし、New → Package
Sensor.kt
作成したパッケージ名を右クリックし、New → Kotlin File/Class
Classを選択し、"Sensor"と名前を付けてEnter
MongoDB Realmのクラウド画面より下記操作で、Kotlinでのデータモデル記述法を確認する
上記操作で確認した内容をコピペし、SensorData.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を作成する
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クラス)
クラスの作成
上記グローバルインスタンスを生成するクラスを、下記手順で作成します。
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}")
}
}
マニフェストに登録
上記クラスをマニフェストに登録します。これによりアプリ起動時に上記インスタンスが初期化されるようです。
AndroidManifest.xmlに、下記のように追記します
<application
:
android:name="com.mongodb.homeiotviewer.InitRealm">
:
4-5. ログイン画面の作成
MongoDB Realmのユーザアカウント作成orログインする画面を作成します。
ログイン画面アクティビティの作成
下記操作で、ログイン画面のアクティビティ(Androidアプリにおける画面GUIに相当する部品)を作成します
javaフォルダ内のパッケージフォルダを右クリックし、New → Activity → Empty Activity
名前を"LoginActivity"に変え、Finish
ログイン画面のレイアウト作成
下記操作で、画面レイアウトactivity_login.xmlを編集します
先ほどの操作でapp → res → layoutに生成した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に下記内容を追記します
<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タブで確認すると、下図のようなレイアウトになっているはずです。
MainActivityからログイン画面への遷移作成
ログイン中ユーザが存在しないときはログイン画面に遷移するよう、
java → パッケージ名のフォルダ内にある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を下記のように書き換えます
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を選択します
Resource Typeを「menu」に変更し、OKを押します
生成したapp → res → menuフォルダを右クリックし、New → Menu Resource Fileを選択します
"logout_menu"と名前を付けてOKを押します
生成した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を下記のように書き換えます
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を記載してコードからアクセスできるようにする)
<?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は、下記のようになるはずです
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"フィールド
が画面に表示されるはずです。
下記のクエリ部分の処理を書き換えることで、取得するデータ内容を変えることができます。
次章で何種類か試してみます。
////////以下の処理は、取得するデータ内容に応じて変える////////
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"
}
フィルタ処理
一致
.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"
}
不等号
.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"
}
範囲
.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"
}
配列指定
.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"
}
文字列操作
.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"
}
論理演算
.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"
}
統計量
以降はsensorsコレクションのデータを使用します
平均、合計
.average()、.sum()を使用
val result = sensorQuery.average("no01_Temperature")
val resultString = result.toString()
最大、最小
.max()、.min()を使用
val result = sensorQuery.min("no01_Temperature")
val resultString = result.toString()
####個数
.count()を使用
val result = sensorQuery.isNull("no03_Temperature").count()
val resultString = result.toString()
※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"
※"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"
※"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型にキャストするように変更しました
'Temperature': sensorValue['Temperature'],
'Temperature': float(sensorValue['Temperature']),
また、このようなスキーマと異なるデータが存在するかどうかの確認は
MongoDB Realmクラウド画面のRulesタブ → 対象コレクションを選択 → Schemaタブ → VALIDATEボタン
で実施可能です。
さいごに
以上で、バックエンド処理の作成は完了しました。
一方で、スマホアプリとして実用的なものとするには、フロントエンド処理を実装してUIを作成する必要があります
フロントエンド部分は本記事の趣旨から外れるので、以下の記事に再び戻って解説させて頂きます
(IoTデータをグラフ表示するAndroidアプリを作成しました)