AWS Summit TokyoでデバイスからLambdaを呼ぶには、FunnelからAWS IoT Coreを通すのがおすすめって聞いてたのに。。。(泣き笑い)っていう人です。(流石に事前に新サービス教えてもらえるわけがないw)
変更履歴
- 2019/07/11 添付したソースに不具合が見つかったのと、一部記述を修正
SORACOM Funk発表
さて、2019/7/3のSORACOM discovery 2019で、「SORACOM Funk」というサービスが発表されました。
今まで、デバイスから、AWS Lambdaを呼び出すには、
- Beam -> Amazon API Gateway -> Lambda
- Funnel -> AWS IoT Core -> Lambda
と言う手段がありましたが、
なんと、
- Funk -> Lambda
で
できるようになりました!
1つなくなっただけですが、これは大きい。
(怒られそうだけど、Amazon API GatewayとかAWS IoT Coreとか微妙にめんどいw)
実のところ、
上記の通り、デバイスから、Funnel経由で、AWS IoT Coreを呼び出して、Lambdaを実行するっていうのを試していたのですが。。。
不要
になりました。はい。
やってみた。
以前作ったSORACOM LTE-M Button powered by AWS(以下 #あのボタン)でボタン押したら、Slackに通知するLambdaがあったので、
LTE-M Button for Enterprise(以下 #しろボタン)とLTE-M Button Plus(以下 #ひげボタン)を押したら、FunkからそのLambdaを呼び出して、Slackに通知する仕組みを作ってみました。
なお、同じことがこちらに詳しく書いてありますー。
ゴールはこんな感じ(通知のアイコンがユニコーンガンダムなのは完全に趣味です。)
上から、#あのボタン #しろボタン #ひげボタン での通知
Slackはすでに設定済みなので割愛してます。すみません。
AWS側
権限設定
FunkからLambdaをInvokeするためのユーザーを作ります。
権限は最小権限だと以下になります。最小と言いつつ、Lambdaは全部叩けるようになっていますが。。。
そして、最初PowerUser権限持ってるユーザーでやってました。。。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SoracomFunkLambdaInvoke",
"Effect": "Allow",
"Action": [
"lambda:InvokeFunction"
],
"Resource": "arn:aws:lambda:*:*:function:*"
}
]
}
作成完了後に表示されるアクセスキーとシークレットキーは紛失すると再発行になるので、忘れずにメモっておく&ダウンロードしておきましょう。漏洩注意(Lambdaしか叩けませんが、それでも何かするかもしれません)
Lambda
ソースはこんな感じ(あまり綺麗でなくてすみません)
- 3種類(#あのボタン と #しろボタン/#ひげボタン)のボタンに対応しています。
- #あのボタンからのリクエストの際は、SORACOM APIの/gadgets/{product_id}/{serial_number}からボタンの情報を取得
- #しろボタン/#ひげボタンからのリクエストの際は、同じくSORACOM APIの/subscribers/{imsi}からSIMの情報(現状利用しているのは名前だけ)を取得しています。
- Funkからのリクエストでは、context.clientContext.customオブジェクト内にIMSIなどの情報が入っています。#あのボタンの場合はeventオブジェクト内にデバイスID等が入っています。
- 今後実装される簡易位置情報等の情報もcontext.clientContext.custom内に入ってくるんじゃないかと。
'use strict'
const request = require('request');
const moment = require('moment-timezone');
const AWS = require('aws-sdk');
AWS.config.update({ region: 'ap-northeast-1' });
const kms = new AWS.KMS();
const encryptedSlackWebHookUrl = process.env['SLACK_WEBHOOK_URL'];
let decryptedSlackWebHookUrl;
const encryptedSoracomKey = process.env['SORACOM_KEY'];
let decryptedSoracomKey;
const encryptedSoracomKeyId = process.env['SORACOM_KEY_ID'];
let decryptedSoracomKeyId;
const clickTypeNameJp = {
'SINGLE': 'クリック',
'DOUBLE': 'ダブルクリック',
'LONG':'長押し'
}
/**
* 初期化
* @param {[type]} event [description]
* @param {[type]} context [description]
* @return {[type]} [description]
*/
const initialize = (event, context) => {
return new Promise((resolve) => {
console.log(JSON.stringify(event, 2));
console.log(JSON.stringify(context, 2));
const stash = {
apiKey:'',
token:'',
serialNumber: '',
imsi:'',
remainingLife: '',
clickType: '',
clickTypeName: '',
reportedTime: '',
batteryLevel: 0,
gadgetInfo:{},
simInfo:{},
isButton: false,
};
if (event.deviceEvent) {
stash.isButton = true;
stash.serialNumber = event.deviceInfo.deviceId;
stash.remainingLife = event.deviceInfo.remainingLife;
stash.clickType = event.deviceEvent.buttonClicked.clickType;
stash.clickTypeName = clickTypeNameJp[`${event.deviceEvent.buttonClicked.clickType}`];
stash.reportedTime = event.deviceEvent.buttonClicked.reportedTime;
} else {
stash.clickType = event.clickType;
stash.clickTypeName = clickTypeNameJp[`${event.clickTypeName}`];
stash.batteryLevel = event.batteryLevel;
stash.imsi = context.clientContext.custom.imsi;
}
console.log(stash);
resolve(stash);
});
};
/**
* キー情報復号化処理
* @param {[type]} stash [description]
* @return {[type]} [description]
*/
const decryptedUrl = (stash) => {
return new Promise((resolve, reject) => {
if (!decryptedSlackWebHookUrl) {
kms.decrypt({ CiphertextBlob: new Buffer(encryptedSlackWebHookUrl, 'base64') }, (err, data) => {
if (err) {
console.log('Decrypt error:', err);
reject(err);
}
decryptedSlackWebHookUrl = data.Plaintext.toString('ascii');
resolve(stash);
});
} else {
resolve(stash);
}
});
};
/**
* キー情報復号化処理
* @param {[type]} stash [description]
* @return {[type]} [description]
*/
const decryptedKey = (stash) => {
return new Promise((resolve, reject) => {
if (!decryptedSoracomKey && !decryptedSoracomKeyId) {
kms.decrypt({ CiphertextBlob: new Buffer(encryptedSoracomKey, 'base64') }, (err, data) => {
if (err) {
console.log('Decrypt error:', err);
reject(err);
}
decryptedSoracomKey = data.Plaintext.toString('ascii');
kms.decrypt({ CiphertextBlob: new Buffer(encryptedSoracomKeyId, 'base64') }, (err, data) => {
if (err) {
console.log('Decrypt error:', err);
reject(err);
}
decryptedSoracomKeyId = data.Plaintext.toString('ascii');
resolve(stash);
});
});
} else {
resolve(stash);
}
});
};
/**
* SORACOM API Auth
* @param {[type]} stash [description]
* @return {[type]} [description]
*/
const getSoracomApiAuth = (stash) => {
return new Promise((resolve, reject) => {
const optionsAuth = {
url: 'https://api.soracom.io/v1/auth',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
json: {
'authKeyId':decryptedSoracomKeyId,
'authKey':decryptedSoracomKey,
'tokenTimeoutSeconds':86400
}
};
// SORACOM Auth APIコール
request(optionsAuth, function (error, response, body) {
if (error) {
console.log('Auth API Error: ' + error);
reject(error);
} else if (response.statusCode != 200) {
console.log('Auth API Error response.statusCode: ' + response.statusCode);
const err = {
'statusCode': response.statusCode
}
reject(err);
} else {
console.log('APIコール成功(auth)');
stash.apiKey = body.apiKey;
stash.token = body.token;
resolve(stash);
}
});
});
};
/**
* SORACOM SIM/ボタン情報取得
* @param {[type]} stash [description]
* @return {[type]} [description]
*/
const getSimGadgetInfo = (stash) => {
return new Promise((resolve, reject) => {
const apiKey = stash.apiKey;
const token = stash.token;
let options = {}
const baseUrl = 'https://api.soracom.io/v1'
if (stash.isButton) {
options = {
url: `${baseUrl}/gadgets/button/${stash.serialNumber}`,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Soracom-API-Key': apiKey,
'X-Soracom-Token': token
}
};
} else {
options = {
url: `${baseUrl}/subscribers/${stash.imsi}`,
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Soracom-API-Key': apiKey,
'X-Soracom-Token': token
}
};
}
// APIコール
request(options, function (error, response, body) {
if (error) {
console.log('Gadgets API Error: ' + error);
reject(error);
} else if (response.statusCode != 200) {
console.log('SORACOM API Error Response.statusCode: ' + response.statusCode);
const err = {
'statusCode': response.statusCode
}
reject(err);
} else {
console.log('APIコール成功 body:' + JSON.parse(body).operatorId);
if (stash.isButton) {
stash.gadgetInfo = JSON.parse(body);
} else {
stash.simInfo = JSON.parse(body);
}
resolve(stash);
}
});
});
};
/**
* Slack POST
* @param {[type]} stash [description]
* @return {[type]} [description]
*/
const postSlackMessage = (stash) => {
return new Promise((resolve, reject) => {
console.log('slack Call');
let messageArray = [];
if (stash.isButton) {
const gadget = stash.gadgetInfo;
messageArray.push(`ボタン ${gadget.tags.name} が${stash.clickTypeName}されました。`);
const useCount = 1500 - gadget.attributes.remainingCount;
messageArray.push(`現在のクリック数は${useCount}で残り${gadget.attributes.remainingCount}です。`);
messageArray.push(`有効期限は${moment(gadget.attributes.contractEndingTime).tz("Asia/Tokyo").format('YYYY-MM-DD hh:mm:ss')}です。`);
} else {
const sim = stash.simInfo;
messageArray.push(`ボタン ${sim.tags.name} が${stash.clickTypeName}されました。`);
const battryLevelInfo = '電池残量は十分です。'
if (stash.batteryLevel < 0.3) {
battryLevelInfo = 'そろそろ電池交換の時期です。'
}
messageArray.push(`バッテリー残量は${stash.batteryLevel}です。${battryLevelInfo}`);
}
const message = messageArray.join('\n');
// リクエスト設定
const options = {
url: decryptedSlackWebHookUrl,
headers: {
'Content-type': 'application/json'
},
body: {
"text": message
},
json: true
};
// メッセージ送信
request.post(options, function(error, response, body) {
if (!error && response.statusCode == 200) {
console.log('Slack API Successful!');
resolve(stash);
} else {
if (error) {
console.log('Slack API Error: ' + error);
reject(error);
} else {
console.log('Slack API Error: ' + response.statusCode);
const err = {
'statusCode': response.statusCode
}
reject(err);
}
}
});
});
};
/**
* Main処理
* @param {[type]} event [description]
* @param {[type]} context [description]
* @param {Function} callback [description]
* @return {[type]} [description]
*/
exports.handler = (event, context, callback) => {
initialize(event, context)
.then(decryptedKey)
.then(getSoracomApiAuth)
.then(getSimGadgetInfo)
.then(decryptedUrl)
.then(postSlackMessage)
.catch(callback);
};
SORACOM側
SORACOMのユーザーコンソールで設定していきます。
- 作ったLambda Invoke用のユーザーのアクセスキーとシークレットキーを認証情報ストアに登録します。
- #しろボタンと#ひげボタン を利用するSIMグループに所属させます。
- 所属させたSIMグループの基本設定を選んで、次の項目を順次設定していきます。
- SORACOM Air for Cellular設定でバイナリーパーサー設定を「ON」にし、フォーマットに「@button」を入力して保存
- **SORACOM Funk設定**で、有効(ON)にした上で、以下を入力して、保存
- サービス : AWS Lambda
- 送信データ方式: JSON
- 認証情報 : 認証情報ストアに登録した認証情報
- 関数のARN : 呼び出し先のLambdaのARN(Lambdaのコンソールの右上に表示されている)
あとは、おもむろにボタンを押す!
Slackに通知がくれば、OKです。
ダメな場合ですが、
ボタンの場合、点滅が15秒ほど続いたあと、赤点滅する場合は、通信に失敗しています。
その前に赤点滅になる場合は、通信はできていますが、
設定が足りないなどで、Lambda関数を呼び出せていない、Lambda関数に問題がある可能性が高いので、
設定やLambda関数を再度確認してみてください。
いや、ほんと楽になりました。
次はWioLTEからリクエストしてみることにします。
流石にボタンじゃレスポンスまでは見れないので。。。