SwitchBot を PC のトレイから操作できるデスクトップアプリを作って、OSS で公開しました。
作った話だけだと持ち帰るものが無いので、SwitchBot API を実際に叩いて分かった仕様のクセも一緒に書きます。ここが一番実用的だと思います。
なぜ作ったか
エアコンをつけたいだけなのに、スマホを取り出してアプリを開くのが面倒でした。
統合的なホームオートメーション(Matter 準拠のものなど)は探せばあります。ただ汎用的なぶん、私には設定が煩雑に感じました。やりたいのは「作業中に手を止めず、1 クリックでエアコンをつける」だけです。
そこで、SwitchBot 専用・トレイ常駐・すぐ操作できる、に絞ったものを作りました。
構成
| レイヤ | 技術 |
|---|---|
| アプリ基盤 | Tauri v2(tray-icon) |
| バックエンド | Rust 2021 |
| フロント | React 19 + TypeScript 5.8 + Vite 7 |
| UI | Tailwind CSS v4 + shadcn/ui + Zustand |
| 認証情報の保管 | keyring 4(OS のセキュアストレージ) |
Rust 約 2,100 行、TypeScript 約 9,700 行です。Windows / macOS / Linux 向けに GitHub Releases で配布しています。
Tauri を選んだのは、バンドルが 3〜10MB で済むこととトレイ常駐に向いていることが理由です。Electron と Wails も候補に挙げましたが、「最近開発が活発らしい」という感触で決めたのが正直なところで、ベンチマークを取って比較したわけではありません。
SwitchBot API のクセ
ここからが本題です。公式の SwitchBot API v1.1 を叩いて実際に引っかかった点を挙げます。
署名は Base64 のあと大文字化する
認証は Authorization / sign / t / nonce の 4 ヘッダです。sign の作り方が独特でした。
HMAC-SHA256(token + t + nonce, secret) → Base64 → 大文字化
最後の大文字化を忘れると通りません。 ここだけで少し詰まりました。
-
tは 13 桁のミリ秒 -
nonceは UUID v4
HTTP 200 でも失敗していることがある
レスポンスは封筒構造になっていて、HTTP ステータスとは別に statusCode を見る必要があります。
{
"statusCode": 100,
"message": "success",
"body": { }
}
statusCode === 100 が成功です。HTTP 200 だけ見て通すと、失敗を成功として扱ってしまいます。
デバイスがオフラインのときは 161 が返ります。私はこれを使ってオフライン検知しています。
401 は認証エラーとは限らない
ここが一番ハマりました。
1 トークンあたり 1 日 10,000 リクエストの上限があり、超えると 401 が返ります。 認証情報が正しくても 401 です。
さらに厄介なことに、v1.1 には残りリクエスト数を返すフィールドも、レート制限用のレスポンスヘッダもありません。 残数を表示する機能は諦めました。
エラーメッセージも「認証情報が違います」とは書けないので、こうしています。
認証情報またはリクエスト上限を確認してください
デバイス一覧の取得は GET /devices 1 回 + 状態を持つデバイスごとに 1 回、という 1 + N 構成になります。トレイの開閉を繰り返すと簡単にバーストするので、自動更新には TTL を入れたほうがいいです。
赤外線デバイスは状態を返さない
エアコンやテレビのような赤外線(学習リモコン)デバイスは、status を取得するエンドポイントがありません。
つまり「いまエアコンが何度に設定されているか」を API から知る方法がありません。アプリ側で「最後に送信した値」を保持しておくのが唯一の表示ソースになります。
エアコンは turnOn / turnOff ではなく、常に setAll で全状態を一括送信する形にしました。
"26,1,3,on" # 温度, モード, 風量, 電源
コマンド送信直後に status を取っても反映されていない
コマンドを送ってすぐ状態を取り直しても、まだ古い値が返ってきます。
即時に再取得すると「楽観的に更新した UI を、反映前の古い値で上書きしてトグルが戻る」という見た目になります。送信後の即時 refresh はやめて、失敗時にロールバックする方式にしました。
実装で気をつけたこと
認証情報を WebView に渡さない
Tauri は WebView とネイティブの二層構造なので、どちらに秘匿情報を置くかを選べてしまいます。
SwitchBotler では署名生成も API 呼び出しも Rust 側で完結させ、WebView にはトークンを一切渡していません。 理由は 2 つです。
- WebView から直接叩くと CORS に引っかかる
- トークンを WebView に置くと露出しやすい
フロントに返す接続状態の DTO は、こうなっています。
pub struct ConnectionStateDto {
pub saved: bool,
}
保存済みかどうかの真偽値だけで、トークンの末尾数文字すら返しません。
トークン自体は keyring crate で OS のセキュアストレージ(macOS Keychain / Windows 資格情報マネージャー / Linux Secret Service)に保存しています。
keyring 4 は既定の feature で Linux 側が pure-Rust の zbus 実装になるため、libdbus のシステム依存が要りません。 CI と AppImage の配布が楽になりました。
開発用の環境変数フォールバックを本番から消す
開発中は毎回 keychain に保存するのが面倒なので、環境変数から読めるようにしています。ただしこの経路が本番に残っていると事故のもとです。
そこで、ランタイムのフラグではなくコンパイル時に消しています。
#[cfg(debug_assertions)]
fn load_from_env() -> Option<Credentials> { /* ... */ }
pub fn load() -> Result<Credentials, SwitchBotError> {
if let Some(creds) = cached_keyring_load() {
return Ok(creds);
}
#[cfg(debug_assertions)]
if let Some(creds) = load_from_env() {
return Ok(creds);
}
Err(SwitchBotError::missing_credentials())
}
release ビルドでは load_from_env 自体がコンパイルされないので、環境変数を拾う経路が存在しません。
エラーに秘匿値を混ぜない
reqwest のエラーは Display に URL が含まれることがあります。そのため From<reqwest::Error> をあえて実装せず、変換のたびに元エラーを捨てています。
.map_err(|_| SwitchBotError::network())?
エラーのコンストラクタも 7 種類に絞って、可変部分はステータスコードの数値だけにしました。
実機を叩くテストをどう書いたか
外部 API の仕様は、モックだけでは確かめられません。実際に叩いたテストを #[ignore] で残しています。
infisical run --env=dev -- cargo test -- --ignored
認証情報は Infisical から環境変数で注入します。前述の #[cfg(debug_assertions)] の経路がそのまま使えるので、テストコードに認証情報の受け渡しを書かずに済みました。
問題は物理デバイスが実際に動いてしまうことです。#[ignore] の理由文字列を副作用の説明に使っています。
#[ignore = "実 AC に副作用(setAll 送信)。安全値のみ・元状態へ復帰。--ignored 指定時のみ実行"]
#[ignore = "実 Bot を物理的に 1 回動作させる副作用あり。press は不可逆。--ignored 指定時のみ実行"]
- エアコンは電源 off の安全値を送り、送信後にもう一度同じ off を送って原状復帰する
- 赤外線ライトは
brightnessUp→brightnessDownで相殺する - Bot の
pressは物理的に押してしまい不可逆なので、pressModeの個体にしか送らない
ログにも秘匿値を出さないようにしている
出力は件数・カテゴリ・エラーコードだけにして、deviceId やデバイス名も伏せています。テストログをそのまま貼れる状態を保つためです。
その他
- 開発期間は 2 日・57 コミット(2026-07-11 〜 07-12)
- MIT ライセンス
- タグを push すると 3 OS 分ビルドして draft リリースを作る構成
- リポジトリ内のバージョンは全部
0.0.0固定にして、CI がタグ名から注入しています。手動 bump を忘れても成果物とタグが必ず一致します
配布しているインストーラはまだ署名していません。 初回起動時に OS の警告が出ます。署名は今後の課題です。
まとめ
SwitchBot API を叩くときに知っておくと良さそうな点をもう一度並べます。
- 署名は Base64 のあと大文字化
- HTTP 200 でも
statusCode === 100を確認する - 401 はレート超過でも返る(1 日 10,000 リクエスト)。残数は取得できない
- 赤外線デバイスは status を返さない。最後に送った値を自分で持つ
- コマンド送信直後の status は反映されていない
同じような常駐ツールを作る方の参考になれば幸いです。


