PCからSwitchBot経由でデスクライトをコントロールする

# はじめに

ボタンにショートカットなどの操作を自由に割り当てられる、Logicoolの「MX Creative Console」を頂き、色々と試している。
特に「ワンタッチでPCをスリープできる」のはとても便利。

デスクライトも同じようにボタン操作できたらもっと便利になると感じた。
現在デスクライトはSwitchBot社のスマートプラグにつなげてあり、Amazon Echo Show経由で音声操作している。

調べると、SwichBot社が[公式API](https://github.com/OpenWonderLabs/SwitchBotAPI)提供していたので、これを使って、スマートプラグのON/OFFをPCから制御できるようにしてみる。

# 準備

## SwitchBotAPIの準備

SwitchBotAPIを使用するのに「トークン」と「クライアントシークレット」が必要。

これらは公式の手順を参考に、SwitchBotアプリから取得する。

https://support.switch-bot.com/hc/ja/articles/12822710195351-%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AE%E5%8F%96%E5%BE%97%E6%96%B9%E6%B3%95

1. SwitchBotアプリを開く -> 「プロフィール」を開く -> 「基本データ」を開く
1. 「アプリバージョン」欄を10回程度タップする
1. 「開発者向けオプション」が表示されるので、開く
1. 「トークン」と「クライアントシークレット」を取得できるので、コピーする

## TypeScriptのプロジェクトを作る

勉強も兼ねて、TypeScriptでAPIを操作するバイナリを作ってみる。

参考：https://qiita.com/oharu121/items/3aadafdd5daa8dc53c64

1. プロジェクトを作成
    `npm init -y`
1. Typescriptをインストールする
    `npm install typescript ts-node @types/node --save-dev`
1. tsconfig.jsonを作成する
    `npx tsc --init`
1. バイナリ化するためのパッケージを追加
    `npm install --save-dev pkg`
1. package.jsonにbinとpkgの設定を追加
    ```
    "bin": "dist/index.js",
    "pkg": {
        "scripts": "dist/index.js",
        "targets": ["node18-win-x64"],
        "outputPath": "release"
    }
    ```

### [メモ]使い方
1. src下にtypescriptのコードを用意する(index.ts)
1. jsコードに変換する
    `npx tsc .\src\index.ts --outDir dist`
1. exeファイルに変換する
    `npx pkg .`
1. release下にexeファイルが出来るので、それを実行する

### [メモ]スクリプトに追加する
1. package.jsonにスクリプトを追加する
```
  "scripts": {
    "build": "npx tsc .\\src\\index.ts --outDir dist && npx pkg ."
  },
```

`npm run build` でバイナリ生成まで一気にできる

# バイナリ作成
 
## Step1. デバイス一覧の取得(動作確認)

まずは、SwitchBotのデバイス一覧が取得できるかを確認する。
SwitchBotAPIのREADMEにJavaScriptのサンプルコードがあるが、これはデバイス操作のコードだったので、以下の記事のコードを使って動かす。

[TypeScriptとNode.jsを使ってSwitchBotのDeviceListを確認する(リクエストの署名)](https://engineering.mobalab.net/2023/12/11/typescript%E3%81%A8node-js%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6switchbot%E3%81%AEdevicelist%E3%82%92%E7%A2%BA%E8%AA%8D%E3%81%99%E3%82%8B%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%AE%E7%BD%B2/)


``` bash
npm install uuid
npm install --save-dev @types/uuid
```

``` index.ts
import * as crypto from "crypto";
import * as https from "https";
import { v4 as uuidv4 } from "uuid";

const token: string = "YOUR_TOKEN";
const secret: string = "YOUR_SECRET";
const t = Date.now();
const nonce = uuidv4();
const date = token + t + nonce;
const sign = crypto.createHmac("sha256", secret).update(date).digest("base64");

const host: string = "api.switch-bot.com";
const path: string = "/v1.1/devices";

const postOptions: https.RequestOptions = {
  host: host,
  path: path,
  method: "GET",
  port: 443,
  headers: {
    "content-type": "application/json",
    authorization: token,
    sign: sign,
    t: t,
    nonce: nonce,
  },
};

function getDevices(getOptions: https.RequestOptions): Promise<any> {
  return new Promise((resolve, reject) => {
    const req = https.request(getOptions, (res) => {
      let queue: Buffer[] = [];
      res.on("data", (chunk) => {
        queue.push(chunk);
      });
      res.on("end", () => {
        const data = Buffer.concat(queue);
        resolve(data.toString());
      });
    });
    req.on("error", (e) => {
      console.log(`request error: ${JSON.stringify(e)}`);
      reject(e);
    });
    req.end();
  });
}

(async () => {
  try {
    const result = await getDevices(postOptions);
    JSON.parse(result).body.deviceList.forEach((devices: any) => {
      console.log(`devices:${JSON.stringify(devices)}\n`);
    });
    JSON.parse(result).body.infraredRemoteList.forEach(
      (infraredRemote: any) => {
        console.log(`infraredRemote:${JSON.stringify(infraredRemote)}\n`);
      }
    );
  } catch (e) {
    console.log(`error:${JSON.stringify(e)}`);
  }
})();
```


## Step2. プラグをON/OFFする

デスクライトは、SwitchBotのスマートプラグに繋いでいる。
スマートプラグはSwitchBotAPIの`devices/{deviceId}/commands`にコマンドを送ることで操作できる。
deviceIdはStep1で取得したdeviceListで確認できる。

https://github.com/OpenWonderLabs/SwitchBotAPI?tab=readme-ov-file#plug-2

SwitchBot公式ドキュメントによると、スマートプラグの操作は以下。
- `command`に`turnOn`を指定するとプラグがON（点灯）
- `command`に`turnOff`を指定するとプラグがOFF（消灯）


``` index.ts
import * as crypto from "crypto";
import * as https from "https";
import { v4 as uuidv4 } from "uuid";

const token: string = "YOUR_TOKEN";
const secret: string = "YOUR_SECRET";
const deviceId: string = "YOUR_DEVICEID";
const t = Date.now();
const nonce = uuidv4();
const date = token + t + nonce;
const sign = crypto.createHmac("sha256", secret).update(date).digest("base64");

const host: string = "api.switch-bot.com";
const path: string = `/v1.1/devices/${deviceId}/commands`;

const body = {
  command: "turnOn", // turnOnとturnOff
  parameter: "default",
  commandType: "command",
};

const postOptions: https.RequestOptions = {
  host: host,
  path: path,
  method: "POST",
  port: 443,
  headers: {
    "content-type": "application/json",
    authorization: token,
    sign: sign,
    t: t,
    nonce: nonce,
  },
};

function controlDevice(
  options: https.RequestOptions,
  body: object
): Promise<void> {
  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      res.on("end", () => {
        resolve();
      });
      res.resume();
    });

    req.on("error", (e) => {
      console.log(`request error: ${JSON.stringify(e)}`);
      reject(e);
    });

    req.write(JSON.stringify(body));
    req.end();
  });
}

(async () => {
  try {
    await controlDevice(postOptions, body);
    console.log(`command sent successfully.`);
  } catch (e) {
    console.log(`error:${JSON.stringify(e)}`);
  }
})();

```

## Step3. プラグの状態を取得する

Step1で利用している全デバイスの一覧を取得したが、deviceIdを指定することで、そのデバイス単体の状態を取得することができる。
`/v1.1/devices/{deviceId}/status`
スマートプラグの現在の状態(ON/OFF)も`power`に入っている。

``` index.ts
import * as crypto from "crypto";
import * as https from "https";
import { v4 as uuidv4 } from "uuid";

const token: string = "YOUR_TOKEN";
const secret: string = "YOUR_SECRET";
const deviceId: string = "YOUR_DEVICEID";
const t = Date.now();
const nonce = uuidv4();
const date = token + t + nonce;
const sign = crypto.createHmac("sha256", secret).update(date).digest("base64");

const host: string = "api.switch-bot.com";
const path: string = `/v1.1/devices/${deviceId}/status`;

const postOptions: https.RequestOptions = {
  host: host,
  path: path,
  method: "GET",
  port: 443,
  headers: {
    "content-type": "application/json",
    authorization: token,
    sign: sign,
    t: t,
    nonce: nonce,
  },
};

function getDevices(getOptions: https.RequestOptions): Promise<any> {
  return new Promise((resolve, reject) => {
    const req = https.request(getOptions, (res) => {
      let queue: Buffer[] = [];
      res.on("data", (chunk) => {
        queue.push(chunk);
      });
      res.on("end", () => {
        const data = Buffer.concat(queue);
        resolve(data.toString());
      });
    });
    req.on("error", (e) => {
      console.log(`request error: ${JSON.stringify(e)}`);
      reject(e);
    });
    req.end();
  });
}

(async () => {
  try {
    const result = await getDevices(postOptions);
    const json = JSON.parse(result);
    console.log(`deviceInfo: ${JSON.stringify(json)}`);
    console.log(`status: ${json.body.power}`);
  } catch (e) {
    console.log(`error:${JSON.stringify(e)}`);
  }
})();

```

応答結果
```
deviceInfo: {"statusCode":100,"body":{"version":"V2.0","power":"on","deviceId":"＊＊＊＊＊","deviceType":"Plug","hubDeviceId":"＊＊＊＊＊"},"message":"success"}
status: on
```

`status`が`on`なら点灯、`off`なら消灯状態だった。

## Step4. プラグの状態に応じて動作を切り替える(トグル制御)

デスクライトのON・OFF用にそれぞれ別々のバイナリを作り、ボタン2つに割り当てる方法もあるが、
プラグの状態をGETして、「ON」なら「OFF」に、「OFF」なら「ON」に切り替えるコードを書き、ボタン1つでデスクライトの操作が完結できるようにする。

``` index.ts
import * as crypto from "crypto";
import * as https from "https";
import { v4 as uuidv4 } from "uuid";

// SwitchBot 認証情報
const token: string = "YOUR_TOKEN";
const secret: string = "YOUR_SECRET";
const deviceId: string = "YOUR_DEVICEID";
const host: string = "api.switch-bot.com";
const path: string = `/v1.1/devices/${deviceId}/status`;

// ヘッダー生成
function buildAuthHeaders() {
  const t = Date.now();
  const nonce = uuidv4();
  const date = token + t + nonce;
  const sign = crypto
    .createHmac("sha256", secret)
    .update(date)
    .digest("base64");

  return {
    t,
    nonce,
    sign,
    headers: {
      "content-type": "application/json",
      authorization: token,
      sign: sign,
      t: t,
      nonce: nonce,
    },
  };
}

// プラグのステータスを取得する関数
function getPlugStatus(): Promise<"on" | "off"> {
  return new Promise((resolve, reject) => {
    const { headers } = buildAuthHeaders();
    const options: https.RequestOptions = {
      host: host,
      path: `/v1.1/devices/${deviceId}/status`,
      method: "GET",
      port: 443,
      headers: headers,
    };

    const req = https.request(options, (res) => {
      let queue: Buffer[] = [];
      res.on("data", (chunk) => queue.push(chunk));
      res.on("end", () => {
        const result = JSON.parse(Buffer.concat(queue).toString());
        const power: "on" | "off" = result.body.power;
        resolve(power);
      });
    });

    req.on("error", (e) => reject(e));
    req.end();
  });
}

// プラグを操作する関数
function controlPlug(command: "turnOn" | "turnOff"): Promise<void> {
  return new Promise((resolve, reject) => {
    const { headers } = buildAuthHeaders();
    const body = JSON.stringify({
      command: command,
      parameter: "default",
      commandType: "command",
    });

    const options: https.RequestOptions = {
      host: host,
      path: `/v1.1/devices/${deviceId}/commands`,
      method: "POST",
      port: 443,
      headers: {
        ...headers,
      },
    };

    const req = https.request(options, (res) => {
      res.on("data", () => {});
      res.on("end", () => resolve());
    });

    req.on("error", (e) => reject(e));
    req.write(body);
    req.end();
  });
}

const postOptions: https.RequestOptions = {
  host: host,
  path: path,
  method: "GET",
  port: 443,
  headers: {},
};

(async () => {
  try {
    const status = await getPlugStatus();
    console.log(`status: ${status}`);

    const command = status === "on" ? "turnOff" : "turnOn";
    console.log(`command: ${command}`);

    await controlPlug(command);
    console.log("command sent successfully.");
  } catch (e) {
    console.log(`error:${JSON.stringify(e)}`);
  }
})();

```

# MX Creative Consoleでバイナリを動かす

Logi Options+で、作成したバイナリを実行するボタンを用意する。
1. 「システムアクション」->「アドバンス」->「実行」をボタンに割り当てる
2. 実行するプログラムに、生成したバイナリを指定する

これで、ボタンを押すだけで、SwitchBotのスマートプラグ＝デスクライトのON/OFFを操作できるようになった。

https://support.logi.com/hc/en-us/articles/29750730987415-MX-Creative-Console-custom-action-Run

# 参考
- https://github.com/OpenWonderLabs/SwitchBotAPI
- [SwitchBotの電球の色を卸電力市場価格連動で変化させる](https://qiita.com/renewable/items/4bc2ccc93e6c04030a0d)
- [[初心者向け] Typescriptプロジェクトを始める](https://qiita.com/oharu121/items/3aadafdd5daa8dc53c64)
- [TypeScriptとNode.jsを使ってSwitchBotのDeviceListを確認する(リクエストの署名)](https://engineering.mobalab.net/2023/12/11/typescript%E3%81%A8node-js%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6switchbot%E3%81%AEdevicelist%E3%82%92%E7%A2%BA%E8%AA%8D%E3%81%99%E3%82%8B%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%AE%E7%BD%B2/)