🦍

Goで家電を操作できるremonadeを作った

2021/07/04に公開

初めに

最近、Nature Remoを買って、家電をスマホで操作できるようにしました。
Nature Remoは赤外線を使って家電を操作できるスマートリモコンで、いくつか種類がありますが、ぼくはNature Remo mini 2を買いました。

スマホで家電を操作できるのはたいへん便利ですが、
普段ずっと仕事などでPCとにらめっこしているので、ターミナルでも家電を操作できたら便利だなと思ってremonade(レモネード)というCLIを作りました。

remonadeについて

Nature RemoAPIを公開しているため、CLIでAPIをたたけばスマホアプリの代わりに家電を操作できます。
たとえばcurlを使うと家電一覧を取れます。

curl -s -H "Authorization: Bearer ${NATURE_REMO_TOKEN}" https://api.nature.global/1/appliances

具体のAPIの仕様はSwaggerが公開されているので、そちらを参照しつつ実装しました。

remonadeは次のような、インタラクティブに家電を選択して操作ができるようなUIにしました。

メインの3つのパネルは次になっています。

パネ 説明
Devices アカウントに紐付いているNature Remoのデバイス一覧
Appliances 登録された家電一覧
Events 操作時に起きたイベント一覧

たとえば、次のスクショではAppliancesでエアコンを選択した状態でoを押すとモードや温度などの詳細な設定を変更する画面が表示されるので、好きに値を変更できます。

次のスクショでは、照明を選択して設定画面を開くとボタン一覧が表示されるので、Enterを押すことでボタンが送信され、照明が変わります。

こんな感じで、remonadeはわりと直感的に使えるかと思います。導入や詳細なキーマップはREADMEを参照してください。

設計と実装

今回作り始める前に、脳内にあったUIイメージをまず次の図にしました。
どんな画面があってどんな操作ができるか、など細かく考えていましたが、
全部図にするより実装したほうが早かったので割とすぐに実装に着手しました。

ただ、上図のようなUIをひとまず作ったところで

  • パネルそれぞれにデータを持っているため、パネル間の状態連携がややこしい
    • たとえば、詳細画面で電源をONにすると、AppliancesのStateもONにする必要があるなど
  • 操作の処理とUI操作処理が密結合になっている
  • テスト書きづらい

という問題にすぐぶち当たりました。
これまでもいくつかこういったCLIを作ってきましたが、どれもうまく上記の問題を解決できなかったです。

今回はじっくり考えた結果、次のようなアーキテクチャにしました。

基本的に

  • 画面操作はActionとして定義し処理する
  • Stateはデータや状態を保持
  • StateはActionによってのみ変更可能とし、Stateが変更された場合、画面を更新

というふうにしたことによって、上記の課題は大まかクリアしました。

ちなみに、イベント駆動はこの類のツールと相性がよいのでは?と考えて、
調べたところvuexのアーキテクチャがまさにイメージしていたものだったのでそれを参考にしました。

実際vuexではミューテーションなど、もう少し細かく定義されていますが、
remonadeはそこまで考慮しなくてもよいかなと思ってシンプルに上図のアーキテクチャにしました。

実際の実装に関して、vuexは次のようにstateactionを定義します。

const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment (state) {
      state.count++
    }
  },
  actions: {
    increment (context) {
      context.commit('increment')
    }
  }
})

それに参考に、remonadeではactionは次のような形にしています。
さきほど書いたとおり、ActionのみStateを変更とするため、第1引数はStateにしています。
そしてほぼすべてのActionはNature RemoのAPIをたたくのでclient、最後にUIから渡されるデータはctxとなっています。

type (
	Action func(state *State, cli *natureremo.Client, ctx interface{}) error
)

StateはAPIから取得したデータを保持しています。

type State struct {
	Devices    []*natureremo.Device
	Appliances []*natureremo.Appliance
	Events     []Event
}

各アクションの処理後にStateが変更されていないかをチェックし、変更があった場合は画面を描画し直しています。

func (d *dispatcher) Dispatch(action Action, ctx Context) {
	old := copyState(d.state)
	d.state.PushEvent(ctx.Event.Type, ctx.Event.Value)
	err := action(d.state, Client, ctx.Data)
	if err != nil {
		UI.Message(err.Error())
		return
	}

	go UI.app.QueueUpdateDraw(func() {
		if !cmp.Equal(old.Appliances, d.state.Appliances) {
			log.Println("update appliance view")
			d.state.UpdateAppliances()
		}

		if !cmp.Equal(old.Devices, d.state.Devices) {
			log.Println("update devices view")
			d.state.UpdateDevices()
		}

		if !cmp.Equal(old.Events, d.state.Events) {
			log.Println("update events view")
			d.state.UpdateEvents()
		}
	})
}

基本、コアな実装はActionになることが多く、そのため、
状態と必要なデータを外部から渡せることによってテストはかなり楽になりました。
それだけでも、このアーキテクチャにしてよかったなと感じています。

今後

こんなことをやっていきたいなって思っています。

  • テストを追加
  • 設定を充実していきたい
    • キーマップのカスタマイズ
    • アイコンのカスタマイズ
  • コマンドベースのインタフェースを追加
    • 家電一覧:remo app list
    • 電源ON:remo power on {app}

まずは、テストをもっと増やして品質を上げたいと思っています。

最後に

いろんな家電があるので、もしかするとremonadeが動かない可能性はあるかなと思っています。
実機テストが自家の家電のみだったので、Nature Remoを持っている方はぜひ試してフィードバックをいただけるとありがたいです。

Discussion