#はじめに
norippyです。またアドベントカレンダーの時期がやってきました!こちらはSORACOM Advent Calendar 2020 3日目の記事です。
私事ですが、最近車を買いました。2000年頃の車です。乗ってわかる・・・これは良い車だ。だけど、その分人気があり、盗難の恐れがあります。最近の車は盗難されにくいようにイモビライザーなどセキュリティがしっかりしているのですが、2000年頃の車は、車の鍵以外にセキュリティがありません。ドアも簡単に開けられてしまいます。こんなガバガバセキュリティをIoTの力で解決したい・・・そう考え、手元にあったSORACOM GPSマルチユニットとLINEを使ってGPSトラッカーセキュリティをつくりました。
参考までに作ったものの詳細はこちら
この記事ではシステムを実現するにあたり開発した、マルチユニットのセンサー情報をトリガーに、LINEにPush通知を送る仕組みについて説明します。GPSマルチユニットを買ったけど、使い道がなくて困っているようでしたら、この記事を参考に開発をしてみてください。
そして最初に申しておきますが、私の本職はハードウェアエンジニアです。プログラミングで仕事をしていないので、レベルは低いです。それを踏まえて優しい気持ちでみていただければと思います。
#開発環境
- macOS catalina 10.15.7
- lambdaのランタイム Node.js 12.X
#システムについて
システムは図で示す構成になっていて、マルチユニットはエンドポイントとなるlamdaにセンサー情報を送るためにSORACOM Funkを利用しています。
またLINEはwebhoookで送ることになるため、API Gatewayを介して、マルチユニットで指定したエンドポイントに情報を送るようにしました。
別々のlambdaでも良いですが、情報を送ったらすぐに処理して欲しいなと思って1つのlambdaにまとめました。ちなみに、この構成でちゃんと動きます。
そしてDynamoDBを使っています。DynamoDBの役割はSORACOMのデバイスと、LINEのIDを紐付けたり、最新の情報を常に保持しておくことで、いつでも情報提供ができたり、変化をキャッチするために利用しています。
#GPSマルチユニットの設定
GPSマルチユニットは購入したらUser Consoleで登録をすることになります。
登録プロセスは割愛しますが、マルチユニットを使う際はSORACOM HarvestをOFFにすることをお勧めします。
理由はとてもシンプルで、お金がかかるからです。同じ情報を別のところにに送ることになり、それにより1日あたり5円かかるので、特に実システム以外でログを見たい等の用途が無いのであればOFFしたほうが良いです。
コンソール画面で左上の[MENU]→[ガジェット管理]→[GPSマルチユニット]を選択。
今回使用するマルチユニットを選択して[デバイス設定変更]を選択。
下図で示すように"SORACOM Harvest Data(Lagoon)"のチェックを外してしまいましょう。
そして、今度は今回の送信先であるSORACOM Funkの設定します。一緒にlambaのエンドポイントを作ります。
SORACOM Funkの設定はSORACOM UG 信州さんで行われたtwilio連携のハンズオン資料がとてもわかりやすかったです。こちらのlambdaの設定手順をみながら進めてください。(環境変数の設定は無視してください)
https://kizawa2020.github.io/iot-showcase/events/soracomug-shinshu7/
lambdaができたらdynamoDBの設定をしておきましょう。こちら、DBのテーブルを作るだけでなく、IAMでFullAccessの許可をONにしておくことをお忘れなく。
#LINEの設定 & API Gatewayの設定。
LINEの設定は
https://developers.line.biz/ja/
こちらにアクセスして、チャンネルを作ります。このときMessaging APIの項目でwebhookの送り先(API Gatewayのaddress)を入力します。
API GatewayはメソッドをPOSTで作成します。
参考までに設定を載せておきます。
メソッドリクエストのヘッダーはX-Line-Signatureにしています。これは、LINEの認証で必要なためです。
統合リクエストの内容はこちら。lambda関数とリージョンはエンドポイントとなるlambdaのものにしなければなりません。
とはいえ、選択するタイプなので、簡単に設定できます。
#lambdaの関数を書く
設定ができたらいよいよコードです。
モジュールをnpmでインストールしておくことをお忘れなく!
コメントはコードの中にコメントアウトで書きました。
//モジュールの呼び出し
const https = require("https"); //API叩くために準備(今は使っていない)
const url = require("url"); //API叩くために準備(今は使っていない)
var aws = require("aws-sdk"); //dynamoDB使うために用意
require('date-utils'); //マルチユニットの情報を取得した時刻を記録すために用意
var docClient = new aws.DynamoDB.DocumentClient({ region: "ap-northeast-1" });
const line = require("@line/bot-sdk"); //LINE Messaging APIを簡単に使うために用意
const crypto = require("crypto"); //LINEの署名が暗号化されているので、これを復号するために使います。
//LINEインスタンス生成
const client = new line.Client({ channelAccessToken: process.env.ACCESS_TOKEN });
exports.handler = async function(event, context, callback) { //DynamoDBを使うので非同期
// console.log("processing event: %j", e);
// console.log("latitude : %f", event.lat);
// console.log("temp : %f", event.temp);
console.log(event)
//そもそも何も入ってなかったらエラー
if (event == null) {
console.log("no data");
callback(null, "success");
}
//Botかどうかの判断をする
//if分でこれはLINEのJSONかSORACOMのJSONかを判断する。
if (event.body !== undefined) { //bodyが定義されていたらLINE BOT。定義されていなかったらSoracom
/*
システムの説明。
Push通知を送るために、LINEからメッセージを1度受け取る必要があります。
なので、適当な文章をLINEで入力して送信します。このIf文はLINEのメッセージからUserIDを抽出しDynamoDBに保存します。
*/
//署名検証 LINEのJSONがこのサービスに対して送られているか確認をする。
let signature = crypto
.createHmac("sha256", process.env.CHANNEL_SECRET)
.update(event.body)
.digest("base64");
// console.log("signature is" + signature);
let checkHeader = (event.headers || {})["X-Line-Signature"];
// console.log("signature complete")
//LINE BOTによるユーザーのメッセージの場合
if (signature !== checkHeader) {
callback(null, "wrong json argument");
}
let body = JSON.parse(event.body); //JSONのBodyをパースする
const events = body.events;
events.forEach(async(event) => { //ここからJsonの中身を確認
let user_id;
//イベントタイプごとに関数を分ける
if (event.type == "message") { // メッセージの場合、命令と判断
if (event.message.type != "text") {
console.log("テキストではないメッセージが送られてきました");
return;
}
user_id = event.source.userId; //これでユーザーIDを登録しておく。
// console.log(user_id)
//LINEのUser IDをDynamoDBで管理。これをしないとPush通知が出来ない。
//このfunction重要です。
UpdateUserId(user_id) //IDを更新する(このコードは一度でいいはずだけど、無駄が多いね。)
}
});
} else { //soracom GPSマルチユニットから送られてきたメッセージだと判断したときの処理
/*
ここではDynamoDBに毎回送られてきたデータを保存しています。
*/
console.log("定期送信 from GPS マルチユニット")
//また一時的にsystem stateをパースしてDBから取り出す
var tableName = "your_dynamodb_table_name"; //事前に作っておいたDBの名前を入力してください。
//まずgetをして、DBの中身を抽出する。
var getParams = {
TableName: tableName,
Key: {
device: "xxxxx", //今回は独自のdeviceというキーを設定しています。デバイスは1つしかなかったので実はDB作る時に直打ちするように書いています。このdeviceの中身を目印にDBの中にある情報を抽出します。
},
};
var result;
try {
result = await docClient.get(getParams).promise(); //ここでDBから情報を抽出。
console.log("get data");
} catch (err) {
result = err
console.log("error");
}
// console.log("after get");
console.log(result);
//ここでDBの処理は一旦置いといて、最新の情報をパースします。
//一番重要なGPSデータ。このデータに欠損があった場合、あえて更新しない
if (event.lat !== null || event.lat !== undefined) {
//DBに書き込む。
console.log("update")
//GPSマルチユニットから送られてきたデータをパースする
const latitude = parseFloat(event.lat); //緯度 (度)
const longitude = parseFloat(event.lon); //経度 (度)
const battery = parseInt(event.bat); //電池ピクト(3段階、-1 は充電中)
const rs = parseInt(event.rs); //アンテナピクト (-1は圏外)
const temp = parseInt(event.temp); //温度 (℃)
const humidity = parseInt(event.humi); //湿度 (%)
const x_acc = parseInt(event.x); //加速度 X (mG)
const y_acc = parseInt(event.y); //加速度 Y (mG)
const z_acc = parseInt(event.z); //加速度 Z (mG)
const eventType = parseInt(event.type); //0:定期送信・加速度割り込み 1:ボタン押下時の送信
//↑event.type等の"type"はSORACOMで定義されているものです。
//詳細はこちらのペイロードの項目を確認ください
//https://dev.soracom.io/jp/gps_multiunit/how-it-works/
// 現在時刻を入力
var dt = new Date();
const time = dt.toFormat('YY年M月D日 HH24時MI分SS秒') //このフォーマットでDBに保存しています。
//DynamoDBに最新のパラメータを保存する。(今回の処理では意味はないですが、参考に)
var params = {
TableName: "your_dynamodb_table_name",
Key: {
device: "xxxxx"
},
ExpressionAttributeNames: {
'#latitude': 'latitude',
'#longitude': 'longitude',
'#battery': 'battery',
'#rs': 'rs',
'#temperature': 'temperature',
'#humidity': 'humidity',
'#x_acc': 'x_acc',
'#y_acc': 'y_acc',
'#z_acc': 'z_acc',
'#event': 'event',
'#timestamp': 'timestamp',
},
ExpressionAttributeValues: {
':add_latitude': latitude,
':add_longitude': longitude,
':add_battery': battery,
':add_rs': rs,
':add_temperature': temp,
':add_humidity': humidity,
':add_x_acc': x_acc,
':add_y_acc': y_acc,
':add_z_acc': z_acc,
':add_event': eventType,
':add_timestamp': time,
},
UpdateExpression: 'SET #latitude = :add_latitude,#longitude = :add_longitude,#battery = :add_battery,#rs = :add_rs,#temperature = :add_temperature,#humidity = :add_humidity,#x_acc = :add_x_acc, #y_acc = :add_y_acc,#z_acc = :add_z_acc,#event = :add_event,#timestamp = :add_timestamp'
}
try {
// DBを更新する
await docClient.update(params).promise();
} catch (err) {
// DBの更新に失敗した場合
console.log('DynamoDB update error:', err);
}
console.log("finished")
//DBに最新の情報をおくるためのメッセージを生成
if (result.Item.user_id == undefined) { //先にuser idを登録するためにメッセージを送ること
callback(null, "success");
}
var pushObject;
var sendingDestination = result.Item.user_id //DynamoDBに保存されているUser id を取得する。
pushObject = await CreateStatusMessage()
console.log("create message complete!")
//push通知をする
console.log("send push message")
console.log(pushObject)
if (pushObject != null || pushObject != undefined) {
client.pushMessage(sendingDestination, pushObject) //LINEに対してpush messageを送る。
}
}
}
callback(null, "success");
};
//function UpdateUserId()
async function UpdateUserId(id) {
var params = {
TableName: "your_dynamodb_table_name",
Key: {
device: "xxxxx"
},
ExpressionAttributeNames: {
'#i': 'user_id',
},
ExpressionAttributeValues: {
':add_id': id
},
UpdateExpression: 'SET #i = :add_id'
}
try {
// DBを更新する
await docClient.update(params).promise();//ここの書き方は大事で、promiseにすることで更新が完了しないと次に進まないようにし、メッセージがちゃんと送れるようにしている。
} catch (err) {
// DBの更新に失敗した場合
console.log('DynamoDB update error:', err);
}
}
//LINEに送るメッセージ(マルチユニットのデータ)を送る。
async function CreateStatusMessage() {
// console.log("check db data")
var tableName = "your_dynamodb_table_name"; //事前に作っておいたDBの名前
//まずgetをして、DBの中身を抽出する。
var getParams = {
TableName: tableName,
Key: {
device: "xxxxx", //device名
}
};
var res;
try {
res = await docClient.get(getParams).promise();
console.log("get data");
} catch (err) {
res = err;
console.log("error");
}
// console.log("after get");
console.log(res);
let db_timestamp = res.Item.timestamp;
let db_latitude = parseFloat(res.Item.latitude);
let db_longitude = parseFloat(res.Item.longitude);
let db_temp = parseInt(res.Item.temperature);
let db_humi = parseInt(res.Item.humidity);
let db_batt = parseInt(res.Item.battery);
let db_rs = parseInt(res.Item.rs);
let messageContents;
if (res.Item.latitude == undefined || res.Item.latitude == null) { //DBに値が入っていない
console.log("send error message")
messageContents = [{
type: "text",
text: "サーバーに情報がありません。デバイスの登録をお願いします。"
}]
} else {
console.log("send db data")
//バッテリーの状態からエンジンがかかっているかかかっていないか判断
let car_state;
if (db_batt == -1) {
car_state = "エンジンON状態"
} else {
car_state = "エンジンOFF状態"
}
//電波状況から文章を考える
let rs_lebel;
switch (db_rs) {
case -1:
rs_lebel = "圏外"
break;
case 0:
rs_lebel = "非常に悪い"
break;
case 1:
rs_lebel = "悪い"
break;
case 2:
rs_lebel = "普通"
break;
case 3:
rs_lebel = "良い"
break;
case 4:
rs_lebel = "非常に良い"
break;
}
console.log("check address api")
//LINEに送る形でメッセージを作る
messageContents = [{
type: "text",
text: `${db_timestamp}に取得した情報です`
}, {
type: "location",
title: "車両の現在位置",
address: "タップすると map が表示されます",
latitude: db_latitude,
longitude: db_longitude
}, {
type: "text",
text: `車両の状態 : ${car_state} \n車内温度 : ${db_temp}\n車内湿度 : ${db_humi}\n通信状況 : ${rs_lebel}`
}];
}
return messageContents
}
これでLINE BOTのお友達になっていると、定期的にマルチユニットで取得した情報が下の画像のように届きます。
汎用性があって良さそうに見えますが、考えてみると、このコードには1つ大きな問題点があることに気づくかと思います。
それはDynamoDBのキーが直打ちになっていること。
この辺り開発時には気づかなかったのですが、payloadではなくcontextの中身を見るとデバイスのIDを取得する事ができるとの情報を頂きました。このデバイスIDとLINEのIDをDynamoDB上で紐づけするようにすれば、ユーザー毎にデバイス管理が出来るようになります。
#最後に
LINEとマルチユニットを繋ぐ一番簡単な方法としてIFTTT経由で送るというのもあるのですが、ちゃんとMessaging APIを使って連携をしてみました。
実際につくってみると相性がいいなと気付きました。これはお勧めの使い方です!ただ、しっかり使ってみるとマルチユニットの方で改善して欲しい点も見つかりました。
外部から給電しているかどうかがわからない、加速度センサーが意外と大きくブレる、電波状況が悪いと加速度トリガーによるメッセージ送信が動かない等・・・
それでも11000円で買えるこのマルチユニットは魅力的なデバイスです。前述の問題も使い方によっては気にならないはず。
今回の記事、開発のヒントにしていただけると幸いです。