1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwitchBot を PC から操作するデスクトップアプリを Tauri + Rust で作った

1
Posted at

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 つです。

  1. WebView から直接叩くと CORS に引っかかる
  2. トークンを 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 を送って原状復帰する
  • 赤外線ライトは brightnessUpbrightnessDown で相殺する
  • 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 は反映されていない

同じような常駐ツールを作る方の参考になれば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?