SORACOM Funkを使ってみた。

AWS Summit TokyoでデバイスからLambdaを呼ぶには、FunnelからAWS IoT Coreを通すのがおすすめって聞いてたのに。。。(泣き笑い)っていう人です。（流石に事前に新サービス教えてもらえるわけがないw）


---
### 変更履歴
- 2019/07/11 添付したソースに不具合が見つかったのと、一部記述を修正

---

# SORACOM Funk発表

さて、2019/7/3のSORACOM discovery 2019で、「[**SORACOM Funk**](https://soracom.jp/services/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に通知する仕組みを作ってみました。

なお、同じことが[こちら](https://dev.soracom.io/jp/start/funk_aws_lambda/)に詳しく書いてありますー。

ゴールはこんな感じ(通知のアイコンがユニコーンガンダムなのは完全に趣味です。)
![通知結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/3ad873b9-93bc-e390-1f86-82fbf19ae4ad.jpeg)
上から、#あのボタン #しろボタン #ひげボタン での通知

***

Slackはすでに設定済みなので割愛してます。すみません。

# AWS側
## 権限設定
FunkからLambdaをInvokeするためのユーザーを作ります。
権限は最小権限だと以下になります。最小と言いつつ、Lambdaは全部叩けるようになっていますが。。。
そして、最初PowerUser権限持ってるユーザーでやってました。。。

```json
{
   "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}**](https://dev.soracom.io/jp/docs/api/#!/Gadget/getGadget)からボタンの情報を取得
- #しろボタン/#ひげボタンからのリクエストの際は、同じくSORACOM APIの[**/subscribers/{imsi}**](https://dev.soracom.io/jp/docs/api/#!/Subscriber/getSubscriber)からSIMの情報(現状利用しているのは名前だけ)を取得しています。
- Funkからのリクエストでは、context.clientContext.customオブジェクト内にIMSIなどの情報が入っています。#あのボタンの場合はeventオブジェクト内にデバイスID等が入っています。
    - 今後実装される簡易位置情報等の情報もcontext.clientContext.custom内に入ってくるんじゃないかと。

 ```js: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用のユーザーのアクセスキーとシークレットキーを**認証情報ストア**に登録します。
<img width="615" alt="認証情報設定例" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/cfcd7999-4582-16cb-7b65-7b13cbe7418d.png">


-  #しろボタンと#ひげボタン を利用するSIMグループに所属させます。
<img width="511" alt="SIMグループ所属状態" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/899cf93c-29a7-cf13-35a5-9e011665e902.png">

- 所属させたSIMグループの基本設定を選んで、次の項目を順次設定していきます。
    - **SORACOM Air for Cellular設定**でバイナリーパーサー設定を「ON」にし、フォーマットに「@button」を入力して保存
<img width="1153" alt="バイナリーパーサー" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/6bc9ae89-7ca7-d9c1-2487-a556aa4099cb.png">

    - **SORACOM Funk設定**で、有効（ON）にした上で、以下を入力して、保存
        - サービス : AWS Lambda
        - 送信データ方式: JSON
        - 認証情報 : 認証情報ストアに登録した認証情報
        - 関数のARN : 呼び出し先のLambdaのARN（Lambdaのコンソールの右上に表示されている）
<img width="704" alt="SORACOM Funk設定で" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/2109e61a-3778-af4f-bd39-f36ac75e06b8.png">
    - **Unified Endpoint設定**で、レスポンスのフォーマットで「SORACOM Funk」を選択して、保存
<img width="811" alt="Unified Endpoint" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/b45a22c3-297d-01cb-db08-e10f02f98a1a.png">

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

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

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

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

# 参考資料
- [SORACOM discovery 2019 B1セッション資料](https://www.slideshare.net/SORACOM/soracom-discovery-2019-b1-sim-iot-153077483)