Closed2

SwitchBot Webhookを使ってデバイスの変更をDiscordで通知する方法

NanaoNanao

はじめに

SwitchBot API と Webhook を使えば SwitchBot 製品のデータ変更を検知して任意の処理が行なえます。

例えばドアの開閉状態やシーリングライトの点灯状態の変更を検知して Discord や LINE にログを残したり、温湿度計の変化を定期的にデータベースに記録したり、任意のキーボードショートカットでエアコンや電源プラグをオンにしたり対話型 AI と組み合わせてあいまいな指示でも家電を操作できるようにしたりと活用法は多岐にわたります。

https://github.com/OpenWonderLabs/SwitchBotAPI

要件

  • SwitchBot 製品のデータ変更時に変更データを受け取りたい
  • 製品タイプや MAC アドレスでデバイスを識別して個別のアクションを実行したい
    • アクションの 1 つとして Discord で通知を受け取りたい
    • Webhook URL を CLI で管理したい

Discord Webhook URL を取得する

まずはプログラムから Discord に任意のメッセージを送れるようにします。
Discord へ任意のメッセージを送るには Discord Webhook を利用します。

  1. PC 版の Discord アプリを起動します。

  2. プライベートチャンネルを作成します。

  3. 「チャンネルの編集」→「連携サービス」と進みます。

  4. 「Webhook」→「新しい Webhook」を選択します。

  5. 一意の Webhook URL が生成されます。

    この URL に対して以下のようなユーザー名やメッセージ本文を POST するとチャンネルにメッセージが投稿されます。
    curl 等で POST して任意のメッセージが投稿できることを確認できました。

Name Type Description
username string ユーザー名
content string メッセージ本文(2000 文字まで)

https://discord.com/developers/docs/resources/webhook

SwitchBot Webhook について

SwitchBot 製品からデータを取得するには API を使う方法と Webhook を使う方法の 2 種類があります。
API を使う場合は CRON 等で定期的にリクエストを送信する必要がありますが、Webhook を使うと製品のデータ変更時に自動で任意の URL にリクエストしてもらうことができます。

つまり Webhook リクエストを受け取るサーバーをこちらで用意しておけばあとは製品の変更時に SwitchBot 側から都度リクエストしてくれるということです。
そしてそのリクエストパラメータには製品のタイプや MAC アドレスやセンサー状態などが含まれています。

https://github.com/OpenWonderLabs/SwitchBotAPI#webhook

Cloudflare Workers プロジェクトのセットアップ

SwitchBot Webhook の送信先サーバーは公開サーバーである必要があります。
今回は手軽にデプロイできる Cloudflare Workers を採用しました。

https://www.cloudflare.com/ja-jp/products/workers/


Cloudflare Workers プロジェクトは wrangler という CLI でセットアップできます。
以下のコマンドを実行して common worker テンプレートを選択するだけで TypeScript 環境がすぐに構築できます。
EditorConfig や Prettier もセットアップ済みなのですぐに開発を開始できるのが嬉しいですね。

npm install wrangler
wrangler login
wrangler init

Cloudflare Workers で Webhook の送信先を作成する(TypeScript)

SwitchBot Webhook はアカウントに紐付く全てのデバイスのあらゆる変更がリクエストされます。
このまま実装すると Discord の通知が大量に届いて通知欄が大変なことになります。

そのため今回は製品タイプを開閉センサーだけに絞って Discord に開閉状態の変更を通知しています。
また、今回は実装していませんがもし同じ製品がフクスウある場合は MAC アドレスで識別もできます。

/src/worker.ts
import { SwitchBotRequest } from "./types/SwitchBotRequest";

// 対象のデバイス種別(WoContactは開閉センサー)
const targetDeviceType = 'WoContact';

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
		const url = new URL(request.url);
		// POST /hooks
		if (request.method === 'POST' && url.pathname.startsWith('/hooks')) {
			let data: SwitchBotRequest | null = null;

			try {
				data = await request.json<SwitchBotRequest>();
			} catch (e) {
				return new Response('Invalid request', { status: 400 })
			}


			// 対象のデバイスかどうか判定する
			if (data.context.deviceType !== targetDeviceType) {
				return new Response('', { status: 204 });
			}

			// Discord Webhookを送信する
			const webhookUrl = 'https://discord.com/api/webhooks/********';
			const resp = await fetch(webhookUrl, {
				method: 'POST',
				headers: {
					'Content-Type': 'application/json',
				},
				body: JSON.stringify({
					username: 'SwitchBot',
					content: data.context.openState === 'open' ? 'ドアが開きました' : data.context.openState === 'close' ? 'ドアが閉じました' : 'ドアが開きっぱなしです',
				}),
			})

			// 結果のステータスを返す
			return new Response(
				`result: ${resp.statusText}`,
				{
					status: 200,
					headers: {
						'Content-Type': 'text/plain',
					}
				}
			);

		}
		// パスに一致しない場合はエラー
		return new Response('Method Not Allowed', { status: 405 })
	},
};

開閉センサーの Webhook リクエストの型は以下の通りです。
製品毎に取得できる context データは異なります。

/src/types/SwitchBotRequest.ts

export type SwitchBotRequest = {
	// イベント種別
	eventType: string;

	// イベントバージョン
	eventVersion: string;

	// 詳細情報
	context: {
		// デバイスのMACアドレス
		deviceMac: string;

		// デバイスの種類(開閉センサー: WoContact)
		deviceType: string;

		// 動体検知(検知: DETECTED, 未検知: NOT_DETECTED)
		detectionState: string;

		// ドアモード(帰宅: IN_DOOR, 外出: OUT_DOOR)
		doorMode: string;

		// 明るさ(明るい: bright, 暗い: dim)
		brightness: string;

		// ドアの開閉状態(開いている: open, 閉じている: close, 開きっぱなし: timeOutNotClose)
		openState: string;

		// イベント送信時のタイムスタンプ
		timeOfSample: number;
	};
};

Cloudflare Workers のデプロイ

以下のコマンドを実行するとlocalhost:8787でサーバーが起動します。
起動中はファイルの変更も即反映されます。

npm run start

curl などで POST して Discord にメッセージが送信されることを確認したら以下のコマンドを実行して Cloudflare へデプロイします。

npm run deploy

SwitchBot のトークンとシークレットを取得する

SwitchBot API v1.1 からは認証の方法が一新されており、トークンとシークレットが必要なようです。
どちらもスマートフォンの SwitchBot アプリから取得できます。

  1. SwitchBot アプリを開いて「プロフィール」→「設定」と進む
  2. 「アプリバージョン」を連打する
  3. すると開発者向けオプションが表示されるのでトークンとシークレットが取得できる

トークンを署名する(Go 言語)

これも SwitchBot API v1.1 からの変更ですが、取得したトークンとシークレットはそのままリクエストに含めるのではなくトークンを署名する必要があります。
具体的にはリクエスト毎にトークンを HMAC-SHA256 で署名してヘッダーにセットする必要があります。

Python や JavaScript のコードは公式の Github に掲載がありますが Go 言語のコード例がなかったため以下のような sign.go を作成しました。

/switchbot/sign.go
package switchbot

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
)

// トークンを署名する
func generateSign(token string, secret string, t int64, nonce string) string {
	v := []byte(fmt.Sprintf("%s%d%s", token, t, nonce))
	h := hmac.New(sha256.New, []byte(secret))
	h.Write(v)
	sign := h.Sum(nil)
	result := base64.StdEncoding.EncodeToString(sign)
	return result
}

この関数を利用して HTTP リクエスト時に毎回トークンを署名します。
以下のような専用クライアントを作成しておくと便利です。

/switchbot/client.go
package switchbot

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"time"

	"github.com/google/uuid"
)

const baseURL = "https://api.switch-bot.com"

type SwitchBotClient struct {
	// アクセストークン
	token string

	// シークレットキー
	secret string

	// HTTPリクエストのタイムアウト
	timeout time.Duration
}

func NewSwitchBotClient(token string, secret string, timeout time.Duration) *SwitchBotClient {
	return &SwitchBotClient{
		token:   token,
		secret:  secret,
		timeout: timeout,
	}
}

func (c SwitchBotClient) SendRequest(method string, path string, body io.Reader) ([]byte, error) {
	req, err := http.NewRequest(method, baseURL+path, body)
	if err != nil {
		return nil, err
	}
	// トークンを署名する
	t := time.Now().UnixNano() / int64(time.Millisecond)
	nonce := uuid.New().String()
	sign := generateSign(c.token, c.secret, t, nonce)

	// 必須のリクエストヘッダーをセットする
	req.Header.Set("Content-Type", "application/json; charset=utf-8")
	req.Header.Set("Authorization", c.token)
	req.Header.Set("sign", sign)
	req.Header.Set("t", fmt.Sprint(t))
	req.Header.Set("nonce", nonce)

	client := &http.Client{
		// リソース節約のためにタイムアウトを設定する
		Timeout: c.timeout,
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("error: bad status %d", resp.StatusCode)
	}
	fmt.Println("status: ", resp.Status)

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	return data, nil
}

使い方は以下の通りです。
API エンドポイント毎にリクエストを関数化しておくと便利です。

body := []byte(`{"action":"queryUrl"}`)
resp, err := c.SendRequest("POST", "/v1.1/webhook/queryWebhook", bytes.NewBuffer(body))

Webhook URL を登録する

Webhook URL の登録は SwitchBot API 経由で行います。
リクエストは先程の方法で毎回署名ヘッダーを追加する必要があります。

Webhook URL を管理する API エンドポイントは主に以下の 3 つです。

動作確認

Cloudflare Workers のデプロイ後に発行された URL https://****.****.workers.dev/hooks を SwitchBot API 経由で登録します。

そしてターゲットのデバイス(今回は開閉センサー)を動作させると直後に以下のような Discord 通知が届くことを確認できました。

  • ドアを開けた時:「ドアが開きました」
  • ドアを閉めた時:「ドアが閉まりました」

おわりに

  • デバイスを動作させてから 1 ~ 2 秒程度で通知が届くのでストレスがない
  • Cloudflare Workers の呼び出しは 1 日 200 ~ 300 回程度なので無料枠に余裕で収まっている
  • 開閉センサーを郵便ポストや冷蔵庫や薬ケースなどドア以外に設置しても面白そう
  • アクティビティを自動記録して自分のライフスタイルを分析するのも面白そう
NanaoNanao

余談

実は PC 版 Discord には通知を読み上げる TTS 機能があります。
有効にするとウィンドウを開かなくても音声でメッセージを確認できて便利です。

このスクラップは2023/06/19にクローズされました