s平面の左側

左側なので安定してます(制御工学の話は出てきません)

Nature Remo + Google Apps Scripts(GAS) + Slack App を使ってサーバ管理不要でプログラム可能なスマートホームを実現する 後編

この記事は「ウィルゲート Advent Calendar 2022」の 6 日目の記事です。

adventar.org

また、同アドベントカレンダー 1 日目の記事の続編でもあります。

(前編はこちら) blog.okashoi.net

スマートリモコンである Nature Remo の Web API を利用してエアコンを操作できる Slack App を作ったお話です。 Google Apps Script(以下、GAS)で動かすことで「無料」かつ「サーバ管理不要」で実現しています。

前編では Nature API、Slack App、GAS プロジェクトの基本的な設定および、動作確認のためのスクリプトを書きました。 後編となる本記事では、インタラクティブな動作をする(=ユーザの入力を受け付ける)Slack App を実装し、Slack 上からエアコンを電源を ON にできるように追加の実装、設定手順を説明します。

  • 前編に書いてあること
    • Nature Remo のセンサ値を取得して Slack に投稿する
  • 後編(本記事)に書いてあること
    • Slack にインタラクティブな要素(ボタン等)を含んだメッセージを投稿する
    • 上記の入力を受け取って Nature Remo 経由でエアコンの電源を ON にする

Slack 上での操作を受け付けてエアコンの電源を ON にするまでの手順

手順 5:GAS で HTTP リクエストを受け取る

Slack 上での操作(ボタンクリック等)は、設定した URL への POST リクエストとなります。 つまり、操作を取り扱うには HTTP のエンドポイントを用意する必要があるのですが、それも GAS で実現できます。

GAS に doPost という名前の関数を定義することで HTTP の POST リクエストを受け取れるようになります。 doPost の引数はリクエストに関する情報を含んでいます。 例えば、以下に示すコードでリクエストボディの JSON を取得できます。

const doPost = (e) => {
  const payload = JSON.parse(e.parameter.payload);

  // 以下にpayload に対する操作を記述
  // :
};

(doPost 関数の詳細についてはドキュメントを参照)

developers.google.com

この状態で、画面右上の「デプロイ」>「新しいデプロイ」をクリックしてリクエストを受け付ける URL を生成します。

「種類の選択」から「ウェブアプリ」を選択、さらに「アクセスできるユーザー」を「全員」に設定*1して「デプロイ」をクリックします。

その後「ウェブアプリ」の欄に表示される URL が POST リクエストを受け取る URL なので控えておきましょう。

手順 6:投稿するメッセージにボタンを設置する

続いて Slack App 側でユーザ操作ができるような設定をします。 Slack App の設定画面に戻りましょう(前編の手順 2 のときの画面)。

サイドバーの「Features」>「Interactive & Shortcuts」をクリックし、遷移した画面のトグルスイッチを On にします。

続いて表示された「Request URL」に先程控えておいた GAS の URL を入力し、画面下部の「Save Changes」をクリックします。

インタラクティブ(ユーザ操作の受け付け)の有効化

これで Slack App 側の設定は完了です。

続いて Slack に投稿するメッセージにボタンを設置します。 投稿メッセージの構築には Block Kit Builder を使うと直感的にできてよいので活用しましょう。

ボタンを設置すると生成される JSON に valueaction_id というプロパティが存在していることが確認できます。 このプロパティに設定した値は、先程設定した「Request URL」に POST されるリクエスト(= GAS の doPost 関数の引数として渡される情報)に含まれるので、「ユーザが、どの要素に対して(action_id)、どのような入力をしたのか(value)」がわかるような値を設定します。

Block Kit Builder 上でボタンを設置すると JSON に value と action_id というプロパティが存在していること

今回は action_id にはどのボタン(「暖房をつける」または「冷房をつける」)かを示す値を、value には「どのエアコンか」がわかる値(appliance の id)を設定します。

最初に示したサンプルコードの一部を次のように変更します(前編同様、実際のコードは共通化やファイルの分割などを行っており、もう少し複雑です)。 これで「人の居る部屋が寒かったら、Slack に『暖房をつける』ボタンがついたメッセージを投稿する」ことができます(まだボタンがついただけで、ボタンをクリックしても何も起きません)。

// 略

const isTooCold_ = (device) => device.newest_events.te.val < 18;

const notifyTooCold_ = (device) => {
  const applianceId = 'xxxxxxxxxxxxxxxxxxxxxxxx'; // Nature Remo(device)に対応するエアコン(appliance) の id(後述)

  const payload = {
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `<!here> ${device.name}は寒くありませんか? :cold_face:`,
        },
      },
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `:thermometer: 室温:${device.newest_events.te.val} ℃`,
        },
        accessory: {
          type: 'button',
          text: {
            type: 'plain_text',
            text: '暖房をつける',
          },
          action_id: 'turnOnWarm',
          style: 'primary',
          value: applianceId,
        },
      },
    ],
  };

  postToSlack_(payload)
};

const main = () => {
  const devices = fetchDevices_();

  devices.filter(isTooCold)
    .forEach(notifyTooCold);
};

ボタン付きのメッセージが投稿される

コード中にある「Nature Remo(device)に対応するエアコン(appliance)の id」については、事前に device id とエアコンの appliance id の対応表を手動で定義しておいても良いですし、 汎用性を持たせるなら例えば次のようなコードで device とエアコンの appliance を紐づけることができます。

const fetchAppliances_ = () => {
  const options = {
    headers: {
      'Authorization': `Bearer ${ScriptProperties.getProperty('NATURE_API_ACCESS_TOKEN')}`,
    },
  };

  return JSON.parse(UrlFetchApp.fetch('https://api.nature.global/1/appliances', options).getContentText());
};

const mergeDevicesAndAcs_ = (devices, acs) => devices.map((device) => ({
  ...device,
  ac: acs.filter((ac) => ac.device.id === device.id)[0],
}));

const main = () => {
  const devices = fetchDevices_();
  const acs = fetchAppliances_().filter((appliance) => appliance.type === 'AC');

  const devicesWithAc = mergeDevicesAndAcs_(devices, acs);

  // devicesWithAc の各要素の ac プロパティで対応するエアコンの appliance にアクセスできる
};

手順 7:Slack からエアコンの電源を ON にする

Slack に投稿されたメッセージのボタンをクリックすることで、GAS の doPost 関数が呼び出されるようになったので、今度は doPost 関数側で受け取ったリクエスト内容をもとにエアコンの電源を ON にする処理を書きます。

Slack 投稿の際に設定した value は、リクエストボディ(JSON)のactions[0].value にセットされています。

Nature API からエアコンの電源を ON にするには POST /1/appliances/{appliance}/aircon_settings エンドポイントを叩きます。

swagger.nature.global

// ※実際のコードには、エラーハンドリングなども含まれます

const doPost = (e) => {
  const payload = JSON.parse(e.parameter.payload);

  // 所定の Slack App からのリクエストかどうかを検証(後述)
  if (payload.token !== ScriptProperties.getProperty('SLACK_APP_TOKEN')) {
    return;
  }

  const applianceId = payload.actions[0].value;

  const options = {
    method: 'post',
    headers: {
      'Authorization': `Bearer ${ScriptProperties.getProperty('NATURE_API_TOKEN')}`,
    },    
    payload: { operation_mode: 'warm' },
  };

  UrlFetchApp.fetch(`https://api.nature.global/1/appliances/${applianceId}/aircon_settings`, options);
}

なお、万が一、対象の URL に Slack App 以外からのリクエストされてしまうと勝手にエアコンの設定を変えられてしまいます。 そこで「所定の Slack App からのリクエストかどうかを検証」の部分では Slack App 設定画面のサイドバーの「Settings」>「Basic Information」から確認できる「App Credentials」の「Verification Token」とリクエストに含まれる token プロパティとを照合しています。

前編の記事に書いた内容と併せて、ここまでの要素を組み合わせることにより最初に掲げた

  • 人の居る部屋が寒かったら(暑かったら)Slack に通知し、ボタンをクリックすると暖房(冷房)を ON にできる
  • エアコンがついている部屋の室温が一定の範囲を超えたら、そのエアコンの設定温度を自動で調整する
    • 調整した際はその旨を Slack に投稿する

という要件を満たすアプリケーションを実装できます。

あとは自分の思うままにプログラムしてカスタマイズするのみです。

その他の設定など

手順 8:GAS の定期実行を設定する

GAS には書いたプログラムを実行するトリガーを設定できます。 今回のプログラムは 30 分に 1 回ほどの頻度で実行されれば十分なので、そのように設定します。

サイドバーの「トリガー」をクリックし、画面右下の「トリガーを追加」をクリックします。

するとモーダルウィンドウが開くので、次のように設定し「保存」ボタンをクリックすれば完了です。

設定項目 設定値
実行する関数を選択 (30 分おきに実行したい関数名)
実行するデプロイを選択 Head
イベントのソースを選択 時間主導型
時間ベースのトリガーのタイプを選択 分ベースのタイマー
時間の感覚を選択(分) 30 分おき

トリガーの設定内容

手順 9:操作内容にもとづいて Slack 上のメッセージを更新

最後に見た目に関する細かい部分にこだわってみます。

ここまでの実装では、ボタンをクリックしてエアコンの電源を ON にしたあとも、Slack 上にはボタンが残り続けてちょっとだけ気持ち悪い感じがします。 そこで、ボタンをクリックしたあとにボタンが消えるような実装にしてみます。

Slack から受け取った HTTP リクエストのリクエストボディ(JSON)には response_url というプロパティが含まれています。 この URL に対して replace_original というプロパティを追加、true をセットしたうえでメッセージ投稿と同様の POST リクエストを送信することで、すでに投稿されているメッセージの内容を上書きすることができます。

また同様に message.blocks というプロパティには元の投稿内容が含まれているので、この値をもとに差分のメッセージを生成できます。

// ※実際のコードには、エラーハンドリングなども含まれます

const doPost = (e) => {
  const payload = JSON.parse(e.parameter.payload);

  // (略:エアコンを ON にするプログラム)
  // POST /1/appliances/{appliance}/aircon_settings エンドポイントへのリクエストが成功すると
  // 現在のエアコンの設定値がレスポンスとして返ってくるのを利用する
  const response = UrlFetchApp.fetch(`https://api.nature.global/1/appliances/${applianceId}/aircon_settings`, options);
  const responseBody = JSON.parse(response);

  const blocks = payload.message.blocks;
  // ボタンを削除
  delete blocks[1].accessory;
  // 末尾にメッセージを追加
  blocks.push({
    type: 'section',
    text: {
      type: 'mrkdwn',
      text: `エアコンをつけました :+1:\n:gear: 設定温度:${responseBody .temp} ℃(暖房 :fire:)`,
    }
  });

  UrlFetchApp.fetch(payload.response_url, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      replace_original: true,
      blocks: messageBlocks,
    })
  });
}

上記のプログラムによって、ボタンをクリックすると投稿内容が更新されてボタンが消えるようになりました。

1 つめの投稿はボタンを押さなかったとき。2 つめの投稿はボタンを押してメッセージが更新されている。

おわりに

前後編に分けて、サーバ管理不要でプログラム可能なスマートホームを無料で(!)実現する方法を説明してきました。

センサー情報をもとに通知をしたり、エアコンを操作したりしてくれる Slack App

Nature API はデータモデルさえ理解してしまえばシンプルな API なので、アイデア次第でもっといろいろなことができそうです。 また、GAS もこれくらいのおもちゃのような用途であれば面倒な設定不要で充分に使えるものでこれまた便利です。

インタラクティブな Slack App の実装のしかたも覚えたので、今後は様々なデバイスや API を駆使しながら日々の生活や仕事をより便利にしていきたいと思います。

そして「ウィルゲート Advent Calendar 2022」の翌日の投稿は内藤さんによる「Amazon AuroraからRDS for MySQLに移行した話」です。 お楽しみに!

*1:文字通り URL を知っている全員からアクセス可能になるので、URL の取り扱いには注意