SwitchBot Hub2のセンサ値の変化時に通知を受け取る

# はじめに
[前回](https://qiita.com/renewable/items/4bc2ccc93e6c04030a0d)に引き続き、Switchbot APIをいろいろ試しています。
前回は、時刻をトリガーとしてプログラムを動かすことで、定期的にSwitchbot電球の色を変化させてみました。今回は、Switchbotのセンサ（照度、気温等）の値の変化をトリガーに、任意のプログラムを動かすことを考えてみます。

## センサの値の変化を受け取るには？
"スマートな"センサであれば、その値が変化したときに、通知を送るという機能が備わっているはずです。このように、何らかのイベントが起きたときWebを用いて外部に通知を行う仕組みは、「Webhook」と呼ばれます。この通知は指定した「Webアプリ」（URLで特定されます）に対するPOSTリクエストの形で行われます。
このWebアプリとは、「ご自身の作った」Webアプリです。スマート電球の色を変えるときにはSwitchBotのシステムを外部から（すなわちご自身が）APIで操作しましたが、今度は逆に、ご自身のWebアプリを作って外部（すなわちSwitchbot）に呼び出してもらうのです。

ご参考：Webhookって何？

https://kintone-blog.cybozu.co.jp/developer/000283.html

## 利用環境
以下のものを準備しました。
#### SwitchBot社のセンサ（ハブ2）
ハブ2は本来はスマートリモコンですが、今回はリモコン機能は使わず、湿度・照度・気温センサとして利用します。

（余談）SwitchBot電球の操作のためにハブ2が必要なのではないかと思い（Amazonでもセットでお勧めされたし）一緒に買ったのですが、電球は直接WiFiにつながるので実はハブ2は必要ありませんでした。一部のSwitchBotデバイスは、WiFiにつながらないためハブ2経由での操作が必要だそうです。なおハブ2は赤外線リモコンになるので、一般の家電を操作するためにも使えます。

ご参考：SwitchBotはハブ無しでも使える？

https://maipyon.jp/switchbot-nohub/

#### プログラミング環境
[前回](https://qiita.com/renewable/items/4bc2ccc93e6c04030a0d)同様、Google Apps Script (GAS)を使用しています。センサ値の出力先として使用するために、コードをGoogleスプレッドシートに紐づけています。
以降の説明中のコードは、特に省略していませんので、基本的に全文コピペすると（ご自身のSwitchBotに関する情報を入力する必要があります）動くようになっているはずです。

:::note warn
GASでこの記事に書かれたコードを実行すると、初回は「ご利用のGoogleアカウントへのアクアセスを許可する必要があります」という警告が出ます。外部サイトへの接続が含まれるためです。この許可を行おうとすると「このアプリはGoogleで確認されていません」という警告が再度出ます。各コードの意味をご自身で確認した上で、許可を行ってください。
:::

# コーディング
## SwitchBot APIをGASから使う準備をする
### 基礎関数を準備する
[前回](https://qiita.com/renewable/items/4bc2ccc93e6c04030a0d)作成したものはそのまま使用します。ただ、各関数に冗長なところがあったので、共通部分を`fetchApp`関数として切り出しました。

定義した関数は以下です。なお今回は種類「デバイス」の関数は使っていません。
| 種類 | 関数名 | 動作 | 利用しているSwitchbot API |
|:-----|:--------|:----------|:-----|
|デバイス|`postCommand(device_id, command, parameter)`| デバイスにコマンドを送る | `devices/(device_id)/commands |
|デバイス|`getStatus(device_id)` | デバイスの状態を取得する | devices/(device_id)/status |
|デバイス|`getDeviceList` | 登録されているデバイスリストを出力する | devices |
|共通|`fetchApp` | URLFetchAppの共通処理 | - |
|共通|`generateHeaders` | 認証情報を含むヘッダを生成する | - |
|共通|`generateSign` | 署名する | - |

~~~js
// URLFetch共通処理
function fetchApp(api, method, payload=''){
  const API_BASE_URL = 'https://api.switch-bot.com';
  var url = API_BASE_URL + api;

  var headers = generateHeaders();
  var options;

  if(method=='get'){
    options = {
      method: method,
      headers: headers,
    }
  }else if(method=='post'){
    options = {
      method: method,
      headers: headers,
      payload: JSON.stringify(payload),
    }
  }
  // console.log(options);

  var res = UrlFetchApp.fetch(url, options);
  // console.log(res);

  var parsed = JSON.parse(res.getContentText());
  console.log(parsed);

  return parsed;
}

//デバイスにコマンドを送る
function postCommand(device_id, command, parameter='default'){
  var api = '/v1.1/devices/' + device_id + '/commands';

  var payload = {
    commandType: 'command',
    command: command,
    parameter: parameter,
  }
  parsed = fetchApp(api, 'post', payload);

  return parsed;
}

//デバイスの状態を取得する
function getStatus(device_id){
  var api = '/v1.1/devices/' + device_id + '/status';

  parsed = fetchApp(api, 'get');
  return parsed;
}

//登録されているデバイスリストを出力する（目的のデバイスのidを目視確認する）
function getDeviceList(){
  var api = '/v1.1/devices';
  var parsed = fetchApp(api, 'get');

  devicelist = parsed['body']['deviceList']
  for(i=0; i<devicelist.length; i++){
    console.log(devicelist[i]);
  }
  devicelist = parsed['body']['infraredRemoteList']
  for(i=0; i<devicelist.length; i++){
    console.log(devicelist[i]);
  }

  return parsed;
}

//認証情報を含むヘッダーを生成する
function generateHeaders(){
  var token = 'ご自分のトークン';
  var secret = 'ご自分のシークレット';
  var t = Date.now().toString(); //文字列にする！
  var nonce = Utilities.getUuid(); //任意の文字列としてUUIDを利用

  var sign = generateSign(token, secret, t, nonce)
  var headers = {
    'Authorization': token,
    sign: sign,
    t: t,
    nonce: nonce,
    'Content-Type': 'application/json',
  };

  return headers;
}

//署名する
function generateSign(token, secret, t, nonce){
  var string_to_sign = token + t + nonce;

  //HMAC-SHA256ハッシュ作成 バイト型が返る
  var signTerm = Utilities.computeHmacSha256Signature(string_to_sign, secret);
  //base64にエンコード、さらに大文字化
  var sign = Utilities.base64Encode(signTerm).toUpperCase();

  return sign;
}
~~~
:::note warn
コードを自分でしか実行しない前提のため「トークン」と「シークレット」をコード中に直接書いています。これらを他人に知られると、他人からSwitchBotアプリにつながっている各種デバイスが操作できてしまうので、くれぐれも注意してください。他人に知られた場合は、SwitchBotアプリからトークンとシークレットを再設定することもできるようです。
:::

### WebhookにWebアプリを設定する関数を書く
コマンドは特定のSwitchBotデバイスに対して送れますが、Webアプリの設定をデバイスごとに行うことはできません。設定できるのは、「私のデバイスの中のどれかで何かが起きたら、この自作Webアプリ（http://...）宛てに通知してください」ということのみです。Switchbotのほうから「どのデバイスで何かが起きた」とそのWebアプリに通知してくれますので、実際にどのデバイスで何が起きたから何を実行しようという動作は、Webアプリの中で記述します。

Webhookの通知先設定のためにもAPIが用意されており、設定、確認、削除ができます。このAPIをGASで利用するために、以下の関数を作成しました。今後直接利用するのは、Webhookを設定する（現在設定されているものがあれば削除する）ための`setWebhook`関数で、Webアプリの初回設定や変更の際に、都度手動で実行します。

定義した関数は以下です。
| 種類 | 関数名 | 動作 | 利用しているSwitchbot API |
|:----|:--------|:----------|:-----|
| Webhook |`queryWebhook`| Webhookの内容を取得する | queryWebhook |
| Webhook |`deleteWebhook(url_delete)` | Webhookを削除する | deleteWebhook |
| Webhook |`setWebhook(url_add)` | Webhookを設定する（現在設定されているものがあれば削除する） | setupWebhook |

~~~js
//Webhookの内容を取得する
function queryWebhook(){
  var api = '/v1.1/webhook/queryWebhook';

  var payload = {
    action: 'queryUrl',
  }
  parsed = fetchApp(api, 'post', payload);

  return parsed;
}

//Webhookを削除する
function deleteWebhook(url_delete){
  var api = '/v1.1/webhook/deleteWebhook';

  var payload = {
    action: 'deleteWebhook',
    url: url_delete
  }

  parsed = fetchApp(api, 'post', payload);
  return parsed;
}

//Webhookを設定する（現在設定されているものがあれば削除する）
function setWebhook(url_add){
  var api = '/v1.1/webhook/setupWebhook';
  var parsed;
  const STATUS_SUCCESS = 100;

  parsed = queryWebhook();
  //既に設定されているURLがあれば削除
  if(parsed['statusCode']==STATUS_SUCCESS){
    // 例
    // { statusCode: 100,
    //   body: { urls: [ 'https://...' ] },
    //   message: 'success' }
    var url_remove = parsed['body']['urls'][0];
    parsed = deleteWebhook(url_remove);
  }

  var payload = {
    action: 'setupWebhook',
    url: url_add,
    deviceList: 'ALL'
  }

  parsed = fetchApp(api, 'post', payload);
  return parsed;
}

~~~

## 自作Webアプリを準備する
### 通知された内容を出力するWebアプリを作ってみる
Webアプリを作成し、SwitchbotのWebhookの通知先に設定します。WebアプリもGASで作れます。まずは、通知された内容をスプレッドシートに出力する、という動作のWebアプリを作ってみましょう。
以下を参考にさせていただきました。というより、まったくここに書かれているとおり、まずはWebアプリ用に新しいスプレッドシートを作成して、App Scriptを開いて、コードをコピペして、「デプロイ」すると、URLが発行されます。

https://qiita.com/ma2shita/items/bf86e46385a8c0b26c74

#### コードをコピペする

コードを引用させていただきます。
> ~~~js
> function doPost(e) {
>   const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
> 
>   sheet.getRange("1:1").insertCells(SpreadsheetApp.Dimension.ROWS);
>   sheet.getRange(1, 1).setValue((new Date).toLocaleString('ja-JP'));
>   sheet.getRange(1, 2).setValue(e);
>   sheet.getRange(1, 3).setValue(e.postData.contents);
> 
>   const output = ContentService.createTextOutput(JSON.stringify({result:"Ok"}));
>   output.setMimeType(ContentService.MimeType.JSON);
>   return output;
> }
> ~~~
出典：@ma2shita(松下 (Max) こうへい) さん　[Google AppScriptでHTTP POST(doPost)を受け付ける最小コード（2022年夏）](https://qiita.com/ma2shita/items/bf86e46385a8c0b26c74)

いきなり`doPost(e)`というのが出てきますが、こういう形式でPOSTやGETリクエストを受け取れる関数を含むものがWebアプリである、ということなので、定型文だと思いましょう。eはイベントを表すオブジェクトです。`e.postData.contents`により、POSTされたデータの本体（「ボディ」）を取得できます。
オブジェクトeの詳細は以下リンク先をご覧ください。

ご参考：GASによるWebアプリについての公式ヘルプページ

https://developers.google.com/apps-script/guides/web

#### デプロイする
GASの[デプロイ] > [新しいデプロイ]からデプロイします。

:::note warn
デプロイ時には、他者であるSwithbotが実行できるようにするため、アクセスできるユーザを「全員」にする必要があります。また、ご自身のスプレッドシートに書き込むために、他者に「自分」ユーザとして（すなわち成り代わって）実行することを許可する必要があります。
このWebアプリのSwitchbotへの設定においてご自身のトークン・シークレットを利用しているため、ここからWebアプリのURLが他人に知られることはないと思われますが、URLの管理には十分注意してください。
:::

「デプロイ」とは配置などと訳される英単語ですが、アプリの場合は、本番環境に移す、という意味です。「版」が管理されており、版ごとに固有のURLが発行されます。

:::note 
新しい版をデプロイしたときも、そのままでは以前の版のURLは引き続き有効です。以前の版は、GASの[デプロイ] > [デプロイの管理]から、アーカイブ（下矢印のアイコン）して非公開にしておきましょう。
:::

### WebアプリをWebhookに設定する

発行されたURLを、前述した`setWebhook`関数でSwitchbotに設定します。この設定は、`setWebhook`関数を定義したほうのスクリプトで記述し実行します（Webアプリ用のスクリプトではありません）。
~~~js
function main(){
  setWebhook('https://script.google.com/macros/s/....../exec');
}
~~~

GAS上でWebアプリのコードを書き換えるだけでは、Webhookで呼び出されるWebアプリの動作は変わりません。デプロイし、さらに、新しいURLをWebhookとして再設定する必要があります。簡単に動作を試せないので、デバッグはちょっと面倒です。

### Webアプリをカスタマイズしていく
#### 通知された内容を観察する

Webhookを設定してからしばらく放置しておくと、Webアプリ用のスプレッドシートに、SwitchBotからの「どのデバイスで何かが起きた」かの情報が蓄積されていきます。最新情報が1行目に挿入され、1列目に日時、2列目にイベント、3列目にSwitchbotからPOSTされたデータの本体が記載されます。
どのデバイスで何が起きたときにどんなデータを返すのかは、SwitchBotの[API仕様書の「Webhoookから受け取るイベント」](https://github.com/OpenWonderLabs/SwitchBotAPI?tab=readme-ov-file#receive-events-from-webhook)の項に書かれています。

上記の`doPost`関数により、スプレッドシートの3列目に、POSTされてきたデータの本体がJSON形式で記載されます。センサ（ハブ2）で何か起きたときのデータの例を、下に示します（見やすいように改行・インデントしています）。
~~~
{
    "eventType": "changeReport",
    "eventVersion": "1",
    "context": {
        "deviceType": "WoHub2",
        "deviceMac": "XXXXXXXXXXXX",
        "humidity":18,
        "lightLevel": 19,
        "scale": "CELSIUS",
        "temperature":13,
        "timeOfSample": 1234567890123
    }
}
~~~
気温、湿度、照度といった情報が含まれていることがわかります。なお、湿度は1ポイント、照度は1レベル、気温は0.2度の変化が通知対象となるようです。deviceMacにはMACアドレスではなく、デバイスの操作の時にも使ったデバイスIDが入っており、センサ（ハブ2）が複数あるときにはこれで判別できます。また、timeOfSampleは13桁のUnixtimeで表現されています。
ただ残念なことに、センサの何かの値が変化したから通知が来たはずなのですが、何が変化したかを読み取ることはできません。前回の通知時からの差分を自分で調べる必要があります。

#### 通知された内容を利用する

POSTされたデータの本体をパースすると、利用しやすくなります。Webアプリの`doPost`関数の中に、パースをし、さらに得られた情報をもとに何かを実行（例えば、Switchbotの電球色を変える）するためのコードを適宜記述してください。

例：パースをして湿度、照度、気温をスプレッドシートの4～6列目に記載する（上記の`doPost`関数への追記）
~~~js
  var params = ['humidity','lightLevel', 'temperature'];
  var parsed = JSON.parse(e.postData.contents)
  console.log(parsed);
  for(i=0; i<params.length; i++){
    console.log(params[i]);
    sheet.getRange(1, i+4).setValue(parsed['context'][params[i]])
  }
~~~

#### おまけ：スプレッドシート上でJSON形式を処理する
スプレッドシート上に既に出力されたJSON形式のテキストから、欲しいデータ項目を「事後に」ピンポイントで取り出すためのスプレッドシート関数がないかと調べてみましたが、すぐに見つからなかったので自作のカスタム関数です。

~~~js
function getJSON(text, keys){
  var res = JSON.parse(text);
  var i;
  for(i = 0; i<keys.length; i++){
    res = res[keys[i]];
  }
  return res;
}
~~~
上記のカスタム関数を記述した後、スプレッドシートのセルに以下の形で数式を書きます。
~~~
=getJSON(セル参照, {"1階層目のキー","2階層目のキー", ...})
~~~
第2引数は配列であり、スプレッドシートからの呼び出し時には波括弧で囲む必要があります。配列の個数は定まっていませんので、JSON形式でどれだけネストしていてもOKです。例えば、A3セルにセンサでイベントが起きた時のJSON形式の情報が入っていて、そのときの気温を取得したいときには、以下を記載します。
~~~
=getJSON(A3, {"context","temperature"})
~~~

ただ、スプレッドシートの多数セルからカスタム関数を呼び出すと目に見えて処理時間がかかるので、GASでコードを書いて一気に処理したほうがよさそうです。

# おわりに

[前回](https://qiita.com/renewable/items/4bc2ccc93e6c04030a0d)の記事投稿の後、SwitchbotのAPI仕様書を眺めていて、次はWebhookを使ってみようと思ったのがきっかけです。そのため、通知は受け取れたものの、肝心の用途はまだ思いついていません。ひとまずはセンサ（ハブ2）でとれた部屋の気温変化などをグラフ化してみています。（ただこの用途であれば、時刻トリガーでも実現できるのですが。）


## 謝辞
コード全体を引用させていただきました。

https://qiita.com/ma2shita/items/bf86e46385a8c0b26c74

すべての基礎となるSwithcBotのAPI仕様書です。

https://github.com/OpenWonderLabs/SwitchBotAPI

ほか、各要素について既に素晴らしい解説記事があるものは、本文中でご参考としてリンクを貼らせていただいています。