【GAS × SwitchBot】とりあえず加湿器にコマンドを送ってみた【Google Apps Script】【SwitchBot API】

こんにちは。
今回はSwitchBotAPIを使って、コマンドを送信し自宅のデバイスを操作する方法についてご紹介します

APIをたたくには何かしらのプラットホームが必要なのですが、ここではGoogle Apps Script(GAS)を使っています。
私はまともに使えるのがGASくらいなのでこれを選んだのですが、他のGoogleアプリとの連携が容易なので、他の言語を使える方でも一考の余地はあると思います。
GASとはいったい何なのかという説明は他の記事に譲るとして、私が触れている中で感じたGASの得意なことなどは、この記事の最後に軽くご紹介したいと思います。

それでは、本題の加湿器についての話を始めましょう。

# そもそも、デバイスの操作ならアプリからできるじゃん？
...まったくもっておっしゃる通り。
ですが、APIを活用してデバイスの操作をしたかったのにはいくつかの理由があります。
### 公式アプリの機能が物足りなかった
加湿器の操作自体は確かにアプリからできます。スケジュール機能を使えば、任意の時刻にON/OFFすることだってできます。
しかし、オートモードで稼働させようとすると、霧化効率（目標湿度ととらえていただければ）がデフォルトで55%に設定され、それ以外の値に自由に設定することはできません。
部屋の湿度には偏りがあり、普段座っている場所や寝ている場所を適度に加湿するには55%では足りません。
アプリから一々湿度を設定するのも面倒なので、コードで指定してしまえばいいやと思った次第です。
### 通知がスマホにしか届かない
たとえば、加湿器を使っているといずれはタンクの水が切れ、アプリを経由してスマホに通知が届きます。
ですが私は普段PCに向かいっぱなし、スマートウォッチの通知も限定しています（あの振動がそんなに好きじゃないのよね...）。
ということで、PCでも確認しやすいSlackに投稿できれば都合がいいな、と思った次第です。
### API操作についての勉強がしたかった
私はプログラミング初心者ですので、使ったことがないものは積極的に使って学習する必要があります。
ですが、仕事がらみでそれをしては事故になりかねません。
プライベートの範囲で収まりつつ、実用性もあるという点で、SwitchBot APIはちょうどいい教材ということです。
うまくいっているかもわかりやすいですしね。
# コード全文
```JavaScript:全文
const properties = PropertiesService.getScriptProperties();
const token = properties.getProperty('SWITCHBOT_API_TOKEN');
const secret = properties.getProperty('SWITCHBOT_API_SECRET');
const listDeviceId = properties.getProperty('DEVICEID').split(','); // カンマ区切りの文字列を配列に変換
const baseURL = 'https://api.switch-bot.com/v1.1';

function main(){
  var command = 'setMode';
  var parameter = '75';

  return ctrlHumidifier(command, parameter);
};

function ctrlHumidifier(command, parameter){
  // tokenとsecretを使って認証
  const {t, nonce, sign} = getAuthParams();

  const headers = {
        "Authorization": token,
        "sign": sign,
        "nonce": nonce,
        "t": t,
        "Content-type": "application/json"
  };

  // mainから引っ張ってきた引数を代入してbodyを構成
  const body = {
          "commandType": "command",
          "command": command,
          "parameter": parameter
  };

  const options = {
        "method": "post",
        "headers": headers,
        "muteHttpExceptions": true,
        "payload": JSON.stringify(body)
  };

  // 加湿器のステータスを取得して水不足かを確かめる
  const idHumidifier = listDeviceId[0];
  const json = getDeviceStatus(idHumidifier);
  const lackWater = json['body']['lackWater'];
  if(lackWater){
    console.log('水不足');
    return postSlack('しめりけ：タンクの水が足りません');
  };
  

  // 水不足でなければコマンドを送信する
  if(command == 'turnOff'){
    const resp = UrlFetchApp.fetch(baseURL + "/devices/" + idHumidifier + "/commands", options);
    console.log(JSON.parse(resp.getContentText()));
  }else{
    const resp = UrlFetchApp.fetch(baseURL + "/devices/" + idHumidifier + "/commands", options);
    console.log(JSON.parse(resp.getContentText()));

    // 起動した回数を加算して7回目以上の時は掃除を促す
    var  countTurnOn = Number(properties.getProperty('COUNT_TURNON_HUMIDIFIER'));
    countTurnOn += 1;
    if(countTurnOn >= 7){
      postSlack('しめりけ：掃除が必要です');
      properties.setProperty('COUNT_TURNON_HUMIDIFIER', 0);
    }else{
      properties.setProperty('COUNT_TURNON_HUMIDIFIER', countTurnOn);
    };
  };

};

function getAuthParams(){
  const t = Date.now().toString();
  const nonce = Utilities.getUuid();
  const data = token + t + nonce;
  const sign = Utilities.base64Encode(Utilities.computeHmacSha256Signature(data, secret)).toUpperCase();

  return {t, nonce, sign};
};

function getDeviceStatus(deviceID, t, nonce, sign){
  const headers = {
        "Authorization": token,
        "sign": sign,
        "nonce": nonce,
        "t": t,
  };

  const options = {
        method: "get",
        headers: headers,
        muteHttpExceptions: true,
  };

  const resp = UrlFetchApp.fetch(baseURL + "/devices/" + deviceID + "/status", options);
    console.log(resp.getContentText());
  var json = JSON.parse(resp.getContentText());

  return json;

};

function postSlack(message){
  const url = properties.getProperty('SLACK_URL');

  const data = {
        "username": "",
        "text": message
  };

  const options = {
        "method": "post",
        "contentType": "application/json",
        "payload": JSON.stringify(data)
  };

  return UrlFetchApp.fetch(url, options);
};
```
# コードの解説
### グローバル変数の設定
```javascript:グローバル変数
const properties = PropertiesService.getScriptProperties();
const token = properties.getProperty('SWITCHBOT_API_TOKEN');
const secret = properties.getProperty('SWITCHBOT_API_SECRET');
const listDeviceId = properties.getProperty('DEVICEID').split(','); // カンマ区切りの文字列を配列に変換
const baseURL = 'https://api.switch-bot.com/v1.1';
```
このスクリプトでは、スクリプトプロパティを使って変数を保存しています。
具体的には、SwitchBotAPIのトークンやシークレットキー、デバイスIDの一覧、後はSlackのWebhookURLなんかも後々出てきます。
これらの共通点は、絶対に外部に漏れてはいけないことです（デバイスIDだけならギリセーフ？）。
ですので、コードにベタ打ちすることを避けるため、スクリプトプロパティに保存して読み書きしています。

スクリプトプロパティは文字列形式で保存されるため、配列などのオブジェクトを保存する際には一行程処理が必要です。
json形式で保存するのが汎用性が高いですが、デバイスID程度ならいいかとカンマ区切りの文字列を保存し、読みだす際にsplit関数で配列に変換しています。

スクリプトプロパティに関する詳しい説明は以下を参考に。
分かりやすく網羅的にまとめていただいてます。

https://qiita.com/chii-08/items/c8bb24c1141eb6ede83e

APIのベースURLも何度も使いますから、ついでにグローバル変数として設定しておきましょう。

### main関数を用意する
今回は「加湿器を起動する」という目的のみなので、わざわざmainを個別に用意する必要は特にありません。
ですが、今後デバイスを増やしたり、条件分岐で挙動を制御する際を考えると、この方が拡張性が高くて都合がいいと思われます。
一般に、この方が可読性も高まりますし。
```javascript:main
function main(){
  var command = 'setMode';
  var parameter = '75';

  return ctrlHumidifier(command, parameter);
};
```
特別説明することは特にありません。
commandとparameterは加湿器のモード設定に使う変数ですので、その際に説明します。
ここでは、その2つの変数をctrlHumidifier関数に引き渡して次に進みます。

### 認証情報を構築する
SwitchBotAPIはv1.1に移行しつつあり、認証方法も単純にトークンを書き込めばよいばかりではなくなりました。
getAuthParams関数を呼び出して署名を作成し、以降の行程のheadersに流し込みます。

```javascript:getAuthParams
function getAuthParams(){
  const t = Date.now().toString();
  const nonce = Utilities.getUuid();
  const data = token + t + nonce;
  const sign = Utilities.base64Encode(Utilities.computeHmacSha256Signature(data, secret)).toUpperCase();

  return {t, nonce, sign};
};
```

ここの手順は以下のサイトを参考に。
おんぶにだっこです。

https://entropiajp.hatenablog.com/entry/2022/09/25/000820

認証情報を取得したら、コマンドに必要なパラメーターを構築していきます。
```javascript:ctrlHumidifier_part1
// tokenとsecretを使って認証
const {t, nonce, sign} = getAuthParams();

const headers = {
    "Authorization": token,
    "sign": sign,
    "nonce": nonce,
    "t": t,
    "Content-type": "application/json"
};

// mainから引っ張ってきた引数を代入してbodyを構成
const body = {
      "commandType": "command",
      "command": command,
      "parameter": parameter
};

const options = {
    "method": "post",
    "headers": headers,
    "muteHttpExceptions": true,
    "payload": JSON.stringify(body)
};
```
コマンドのパラメータの設定に関しては、公式のマニュアルを参考に。
ここまでまとめてくれるのはすごく親切ですね。
今回はcommandをsetModeに、parameterを75に設定し、「霧化効率75%のオートモードで運転してくれ」という指示を送っています。

https://github.com/OpenWonderLabs/SwitchBotAPI#bot-2

### デバイスの状態を取得する
加湿器にコマンドを送る前に、空焚き防止（超音波式の加湿器にこの表現は妥当か...？）のためにステータスを取得します。
恐らくですが、加湿器自体の造りから水不足の時に動かないようにはなっていると思われます。
ですが念のため、そして水不足の通知を投稿するためにステータスを確認します。
```javascript:ctrlHumidifier_ステータスチェック
  // 加湿器のステータスを取得して水不足かを確かめる
  const idHumidifier = listDeviceId[0];
  const json = getDeviceStatus(idHumidifier);
```

getDeviceStatus関数にデバイスIDと先に取得した認証情報を渡すと、ステータスに関するJSON形式のテキストを受け取り、翻訳されたオブジェクトが返ってきます。
```javascript:getDeviceStatus
function getDeviceStatus(deviceID, t, nonce, sign){
  const headers = {
        "Authorization": token,
        "sign": sign,
        "nonce": nonce,
        "t": t,
  };

  const options = {
        method: "get",
        headers: headers,
        muteHttpExceptions: true,
  };

  const resp = UrlFetchApp.fetch(baseURL + "/devices/" + deviceID + "/status", options);
    console.log(resp.getContentText());
  var json = JSON.parse(resp.getContentText());

  return json;

};
```
返されたオブジェクトのbodyのlackWaterの項を参照すると、水不足に関する情報がtrue/falseのブーリアン値で保存されています。
lackWater == tureの時にはSlackに通知を投稿し、のちの処理はスキップします。
```javascript:
  const lackWater = json['body']['lackWater'];
  if(lackWater){
    console.log('水不足');
    return postSlack('しめりけ：タンクの水が足りません');
  };
```
Slackへの投稿はpostSlack関数を用意しています。
送りたいメッセージを渡せば投稿してくれるシンプルな構造です。
```javascript:postSlack
function postSlack(message){
  const url = properties.getProperty('SLACK_URL');

  const data = {
        "username": "",
        "text": message
  };

  const options = {
        "method": "post",
        "contentType": "application/json",
        "payload": JSON.stringify(data)
  };

  return UrlFetchApp.fetch(url, options);
};
```
Slackへのメッセージ投稿は以下のサイトを参考に。

https://specially198.com/how-to-notify-using-slack-incoming-webhook-on-gas/

### 加湿器にコマンドを送信する
コマンドはturnOffとそれ以外とで条件を分岐させます。
起動した回数を記録し、7回目以降は加湿器を掃除するように促すメッセージをSlackに投稿します。
```javascript:ctrlHumidifier_コマンド送信
// 水不足でなければコマンドを送信する
if(command == 'turnOff'){
  const resp = UrlFetchApp.fetch(baseURL + "/devices/" + idHumidifier + "/commands", options);
  console.log(JSON.parse(resp.getContentText()));
}else{
  const resp = UrlFetchApp.fetch(baseURL + "/devices/" + idHumidifier + "/commands", options);
  console.log(JSON.parse(resp.getContentText()));

  // 起動した回数を累積して7回目以上の時は掃除を促す
  var  countTurnOn = Number(properties.getProperty('COUNT_TURNON_HUMIDIFIER'));
  countTurnOn += 1;
  if(countTurnOn >= 7){
    postSlack('しめりけ：掃除が必要です');
    properties.setProperty('COUNT_TURNON_HUMIDIFIER', 0);
  }else{
    properties.setProperty('COUNT_TURNON_HUMIDIFIER', countTurnOn);
  };
};
```
この掃除を促す機能については、正直あまり出来のいいものではないなとは感じています。
極端な話、1分間に連続してturnOnコマンドを7回実行しても通知が届けられるのです。
1日1回のトリガーで起動させているうちはいいのですが、今後より細かに制御するのに向けて、スプレッドシートにログを残させたりしようかなとも考えています。
# 所感など
### 初めてのAPI操作にはちょうどいい難易度だった
今回が全くの初めてのAPI操作だったかと問われれば、そうでもないのですが...（SlackAPIは経験があった）
各種サイトに加え、公式のマニュアルも読みこみ、POSTもGETもするという点で、ようやくしっかり足を踏み入れたかなといった感覚です。
また、はっきりと「この目的を達成したい」という動機をもって行ったのも初めてな気がします。

SwitchBotAPIは公式のマニュアルも充実していますし、先人の軌跡も多数公開されています。
まずはデバイスの電源を入れる所から、あなたも始めてみませんか？

### トラブルにも見舞われた
実はこのコードを組むにあたり、まったく思った通りに行かずに詰まった部分がありました。
それはコマンドのheadersの設定です。
```javascript:
"Content-type": "application/json"
```
この一文を入れていないがばかりに、API側がコマンドを読み取ってくれなかったのです。
SwitchBotくんに「何言ってはるん？　あんさんの言葉はわかりませんわ。」と何度も言われました。
ある種のお作法的な、当たり前なところなのでしょう。しかし、全くの素人である私は勘所をつかめておらず、結局解決までに1週間程度かかりました。
この手のことは明示的に教えてくれるテキストも少ないですから、数をこなして習得する他はないかなとも思います。

### たのしい
自分が書いたコードが動いてくれるのは、やはり楽しいです。
これまでも、VBAやGASをつかってアプリを作ったりはしてきました。
ですが、今回は加湿器というデバイスを介し、リアルな世界に直接的に干渉している点で一味違います。
今度はセンサーを増やしたり、コードをAPIとしてデプロイしたりして、双方向に動かす機能を実装したいなと思っています。

# おわりに
今回は加湿器にフォーカスして、SwitchBotApiを使ったデバイス操作についてご紹介しました。
アウトプットがてらに記事を書いてみたのですが、書こうと思い立った直接的な動機として、SwitchBotAPIをGASで制御する記事の中でも加湿器に関するものは見当たらなかったというものがあります。
別に、他のデバイスや他の言語の記事ならあるのですが、せっかく組み上げたのだから誰かのためになればとご紹介している次第です。
ご自身の責任の範囲で、ご自由にお使いください。

このコード自体には少し修正した箇所がいくつかあり、例えば掃除を促す基準なんかは、単純な起動回数から連続稼働日数に変更したりしてみたいですね。
後は起動自体をトリガーに任せていますが、他のセンサーと連携させて詳細な制御をしたいなとかも考えています。

これからも不定期に記事を投稿したいと思っておりますので、ご縁があれば何卒。
# 余談：GASはいいぞ
今回のコードを実装するにあたり、プラットホームとしてGASを利用しました。
他にも選択肢は数多ありますが、GASを使うことのメリットを少し紹介したいと思います。

### 1. 気軽に始めやすい
GASを使うにあたり必要なのは、Googleアカウントだけです（PCやキーボードも必要というツッコミは野暮ですぜ）。
スクリプトはすべてサーバー側に保存され、いつでも簡単に実行できます。
特別なアプリのインストールもいりません。
これらがGASの最大の強みでしょう。

### 2. 実用性が高い
GASは本来、Googleのアプリを統合的に運用できるように作られた言語です（という認識）。
ですので、スプレッドシートやGmail、フォームなど、他のアプリと連携した処理を行うのに最適です。
これらアプリを一度も使ったことが無いという人は少なく、むしろ仕事で多々利用するという方が多いのではないでしょうか。
それらを連携させたり、自動化したりすることで、業務の生産性を上げられるのは想像に難くありません。
GASでの経験を高めることで、日々の仕事にプラスのフィードバックができるのです。

### 3. プログラミングの勉強になる
GASはJavaScriptをベースに作られた言語です。
本家と比べて出来ることはやや限られますが、基本的な構文は似通っており、ライブラリの導入などの拡張機能も充実しています。
また、サーバーベースの運用のため、VBAと比較すると、API操作など外部との連携が得意な印象です。
諸先輩方を見ても、VBAからプログラミングの道を歩み始めたというケースもままあり、その道すがら立ち寄ってもみてもいいのではないでしょうか。
よりプログラミングらしい、発展的な感触を得られる、そんな風に思います。

# 参考資料
要所要所で参考にしたサイトは紹介しましたが、全体を通して参考にしたサイトを以下に掲示します。

- [Google SpreadsheetからGASでSwitchBot API v1.1にアクセスして消費電力を可視化する](https://entropiajp.hatenablog.com/entry/2022/09/25/000820)
特にお世話になったサイトなので再掲。
認証情報の取得からGETリクエストまでの一連の流れを、この記事のコードを参考に構築しました。
この記事のおかげで始め方や勝手をつかめたように思います。

https://entropiajp.hatenablog.com/entry/2022/09/25/000820

- [GASでSwitchBot API にリクエストを送り物理ボタンを制御する](https://qiita.com/raito_raito_01/items/a55f55541c813da872cb)
POSTリクエストのコードを書く際に参考にしたサイト。
また、上記のトラブルに見舞われた際に見返してみて、コードを丸写しでテストしてみて、解決の糸口をつかんだ記事でもあります。
v1.0の記事なので認証部分は改変する必要がありますので、そこは上の記事を参考に。
あと、この記事中のコードでテンプレートリテラルの存在を初めて知りました。

https://qiita.com/raito_raito_01/items/a55f55541c813da872cb

- [SwitchBotAPIを使ってPythonコードにて空調を操作してみた](https://dev.classmethod.jp/articles/switchbot-control-by-api/)
こちらはPythonの記事ですが、POSTリクエストに関して参考にしていました。

https://dev.classmethod.jp/articles/switchbot-control-by-api/