LoginSignup
9
3

More than 3 years have passed since last update.

SORACOM Funkを使ってみた。

Last updated at Posted at 2019-07-05

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内に入ってくるんじゃないかと。
index.js
'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グループ所属状態

  • 所属させたSIMグループの基本設定を選んで、次の項目を順次設定していきます。

    • SORACOM Air for Cellular設定でバイナリーパーサー設定を「ON」にし、フォーマットに「@button」を入力して保存 バイナリーパーサー
    • SORACOM Funk設定で、有効(ON)にした上で、以下を入力して、保存
      • サービス : AWS Lambda
      • 送信データ方式: JSON
      • 認証情報 : 認証情報ストアに登録した認証情報
      • 関数のARN : 呼び出し先のLambdaのARN(Lambdaのコンソールの右上に表示されている) SORACOM Funk設定で
    • Unified Endpoint設定で、レスポンスのフォーマットで「SORACOM Funk」を選択して、保存 Unified Endpoint

あとは、おもむろにボタンを押す!

Slackに通知がくれば、OKです。

ダメな場合ですが、
ボタンの場合、点滅が15秒ほど続いたあと、赤点滅する場合は、通信に失敗しています。
その前に赤点滅になる場合は、通信はできていますが、
設定が足りないなどで、Lambda関数を呼び出せていない、Lambda関数に問題がある可能性が高いので、
設定やLambda関数を再度確認してみてください。


いや、ほんと楽になりました。
次はWioLTEからリクエストしてみることにします。
流石にボタンじゃレスポンスまでは見れないので。。。

参考資料

9
3
0

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
9
3