Goで家電を操作できるremonadeを作った
初めに
最近、Nature Remoを買って、家電をスマホで操作できるようにしました。
Nature Remo
は赤外線を使って家電を操作できるスマートリモコンで、いくつか種類がありますが、ぼくはNature Remo mini 2
を買いました。
スマホで家電を操作できるのはたいへん便利ですが、
普段ずっと仕事などでPCとにらめっこしているので、ターミナルでも家電を操作できたら便利だなと思ってremonade(レモネード)というCLIを作りました。
remonade
について
Nature Remo
はAPIを公開しているため、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
にする必要があるなど
- たとえば、詳細画面で電源をONにすると、
- 操作の処理とUI操作処理が密結合になっている
- テスト書きづらい
という問題にすぐぶち当たりました。
これまでもいくつかこういったCLIを作ってきましたが、どれもうまく上記の問題を解決できなかったです。
今回はじっくり考えた結果、次のようなアーキテクチャにしました。
基本的に
- 画面操作はActionとして定義し処理する
- Stateはデータや状態を保持
- StateはActionによってのみ変更可能とし、Stateが変更された場合、画面を更新
というふうにしたことによって、上記の課題は大まかクリアしました。
ちなみに、イベント駆動はこの類のツールと相性がよいのでは?と考えて、
調べたところvuexのアーキテクチャがまさにイメージしていたものだったのでそれを参考にしました。
実際vuex
ではミューテーションなど、もう少し細かく定義されていますが、
remonade
はそこまで考慮しなくてもよいかなと思ってシンプルに上図のアーキテクチャにしました。
実際の実装に関して、vuex
は次のようにstate
とaction
を定義します。
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