_・)Keep Smiling

モソモソと日々の雑感を書き溜めるというか書き殴るというか書き捨てるというか

_・)Ansibleでカーテンとか玄関の鍵を開け閉めする

この記事は Ansible Advent Calendar 2024 の15日目の記事です。

最近のAWXがどうなってるかなーって調べてインストールでもやってみようかと思いましたが、2017年の自分とネタ一緒やんけ。何も成長してないな?ってなったのでやめました。

というわけで今回はAnsibleから自宅のカーテンや玄関の鍵の状態を確認したり、開け閉めしたりしてみようと思います。

セキュリティ?なにそれおいしいの??()

はじめに

前回、Ansibleのアドカレに参加したのは先にも書いた2017年だったのですが、その時も心の師匠(または失笑)の @habuka036 さんが派手にやらかしてくれていました。

今年もしょっぱなからやらかしてくれていたので安心してネタが書けますね。さすが鰻師はひと味違います。感謝と愛の正拳突きです。

youtu.be

今回やりたいこと

閑話休題

さて、今回Ansibleでいじるのは、私のお家のスマート的なアレコレです。

現在、私の自宅ではSwitchbot社のデバイスがかなり幅を効かせています。

スマートロックやらカーテンボットやら、日々のちょっとした便利さを享受するために、ブラックフライデーなどでの課金がもうどうにも止まりません。今年も玄関の鍵を通常版のロックから、上位版のロックProに替えました。

本当に必要だったのか? とか、余計なことは考えてはいけません。同じ流れで少し前にカーテンボットも上位版に替えていますが、余計なことは考えてはいけません。

さて、そんな散財愛好家にピッタリなソリューションを日夜たたき込んでくるSwitchbotですが、素晴らしいことにこれらのデバイスをインターネット経由でコントロールするための仕組み(API)を公開してくれています。

github.com

ユーザーであれば、1日当たりの利用回数に制限はあるものの無料で使えます。無償で使えてしまうAPIがある時点でほぼ勝ち確です。ザクとは違うのだよ、ザクとは!(ザク大好きです💕)

今回は、このAPIをAnsibleから叩いてアレコレやってみます。はい。

Switchbot API 利用の準備

現在公開されているSwitchbot API V1.1では、利用にあたって認証の為に、ユーザーのトークンとクライアントシークレットが必要です。

これらは、Switchbotの公式スマホアプリから入手できるのですが、ちょっと特殊な操作(アプリバーションを10回タップする・・・)をしなくてはいけません。

詳しくは公式のBlogでも解説がある(けどちょっと画面とか古い)ので、こちらをまずは参考にしてみてください。

blog.switchbot.jp

私の手元のスマホからやった感じだとこんな風になりました(トークンとクライアントシークレットにはマスク処理をしています)。

バージョンを10回タップすると出てくる開発者向けオプションとトークン&クライアントシークレット

トークンとクライアントシークレットを無事に入手しましたので、いよいよAPIドキュメントも参考にしながらAnsibleから色々していきましょう!

Ansible環境のセットアップ

すでに皆様のお手元にはいろんなAnsible環境が転がっているかと思いますが、私も自分のマシン(M2なMac mini)の中でAnsibleサーバーを用意したいと思います。

$ python3 -m venv AdCal2024
$ source AdCal2024/bin/activate
$ pip install --upgrade pip
Requirement already satisfied: pip in ./AdCal2024/lib/python3.9/site-packages (21.2.4)
Collecting pip
(略)
Successfully installed pip-24.3.1
$ pip install ansible-core
Collecting ansible-core
  Downloading ansible_core-2.15.13-py3-none-any.whl.metadata (7.0 kB)
(略)
Successfully installed MarkupSafe-3.0.2 PyYAML-6.0.2 ansible-core-2.15.13 cffi-1.17.1 cryptography-44.0.0 importlib-resources-5.0.7 jinja2-3.1.4 packaging-24.2 pycparser-2.22 resolvelib-1.0.1
$ ansible --version
ansible [core 2.15.13]
(略)

はい、無難にインストール完了です。 Pythonで「AdCal2024のvenv環境」を作成しアクティベートし、さらにpipのアップデートとansible-coreのインストールまでをサクッと実行したわけですね。

ここで一仕事おわり!ビール!!としたい気持ちをグッとこらえて、先に進みましょう。

機密情報を安全に利用する

さて、今回利用するトークンとクライアントシークレットは漏洩すると悪意のある第三者に真夜中にカーテンを開け閉めされたりして危険が危ないです。真面目に書くと玄関の鍵まで開け閉めできちゃうのでガチで危険が危ないです。

特にここではプレイブックをGithub等で管理する予定はないものの、やはり機密情報を平文のまま扱ったりするのは精神衛生上よくありませんので、Ansible Vaultを使って暗号化しておきたいと思います。

Switchbot APIの利用時には、このトークンとクライアントシークレットから生成する署名が必要です*1。この署名の値は、トークンやクライアントシークレットが変化しない限りは同じですので、今回は先に署名を作成しておいて、それをAnsible Vaultで暗号化して利用していきたいと思います。

署名の作成方法には色々な方法がありますが、コンソール上で署名を作成するには、以下のコマンドを実行します。

$ token="スマホアプリからコピーしたトークン"
$ t=$(date +%s%3N)
$ nonce=$(uuidgen)
$ sign=$(echo -n "${token}${t}${nonce}" | openssl dgst -sha256 -hmac "スマホアプリからコピーしたクライアントシークレット" -binary | base64)
$ mkdir vars
$ cat << EOF > vars/sign.yaml
---
token: ${token}
t: ${t}
nonce: ${nonce}
sign: ${sign}
EOF
$ ansible-vault encrypt  vars/sign.yaml
New Vault password: 任意のAnsible Vaultのパスワードを入力
Confirm New Vault password: 同じAnsible Vaultのパスワードを再度入力
Encryption successful

これで署名に関する変数が格納された暗号化済定義ファイルが準備できました。 この変数定義ファイルを利用する場合は、コマンドオプションで--ask-vault-passを入力し、Ansible-Vaultパスワードを都度入力します。

ansible-vaultで暗号化された変数はansible-playbookコマンド実行時に詳細オプション(-v)をつけて実行してしまうと、標準出力にその内容が出力されてしまいます。これを避ける為にはタスクに対してno_log: trueを指定します。ただし、その場合でもANSIBLE_DEBUG環境変数にてデバッグを有効化すると情報が出力されてしまうことには注意してください。

各デバイスのID確認

Switchbotの各デバイスを制御するためには、それぞれのデバイス固有のIDを指定する必要があります。ここではまず自分が使っているデバイスの名前とIDを一覧表示させるためのPlaybookを書いてみます。

---
- hosts: localhost
  vars_files: vars/sign.yaml
  tasks:
    - name: Get Device List
      ansible.builtin.shell: |
        curl -s 'https://api.switch-bot.com/v1.1/devices' \
             -H 'Authorization: {{ token }}' \
             -H 'sign: {{ sign }}' \
             -H 't: {{ t }}' \
             -H 'nonce: {{ nonce }}' \
             -H 'Content-Type: application/json' \
             | jq -r '.body.deviceList[] | "\(.deviceName): \(.deviceId)"'
      register: result
    - name: Display Device Name and Device ID
      ansible.builtin.debug:
        var: result.stdout_lines

本当はURIモジュールあたりを使ってもう少し格好良くやりたかったのですが、どうしても認証が上手く出来なかったので今回は泥臭いですがShellモジュールでやっていきます。Shellモジュールでいつも解決してしまう僕を神様どうか許してください。

■ 実行結果

$ ansible-playbook --ask-vault-pass FindingDeviceInfo.yaml
Vault password: Ansible Vaultのパスワードを入力
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] **************************************************************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Get Device List] ********************************************************************************************************************************************************************************************************************************************************************
changed: [localhost]

TASK [Display Device Name and Device ID] **************************************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "result.stdout_lines": [
        "ハブミニ: XXXXXXXXXXXXX",
        "ハブ2: XXXXXXXXXXXXX",
        "カーテンみぎ: DDDDDDDDDDDDD",
        "カーテンひだり: XXXXXXXXXXXXX",
        "カメラボックス温湿度計: XXXXXXXXXXXXX",
        "リモートボタン: XXXXXXXXXXXXX",
        "ロックPro Down: XXXXXXXXXXXXX",
        "キーパッド: XXXXXXXXXXXXX",
        "ロックPro Up: OOOOOOOOOOOOO",
        "温湿度計: XXXXXXXXXXXXX",
        "リビングカメラ: XXXXXXXXXXXXX"
    ]
}

PLAY RECAP ********************************************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ここでは実際のデバイスIDはマスクしてますが、問題無く実行できていればそれぞれのデバイスIDが確認できるはずです。

バイスの状態を確認する

先ほどの実行結果のうち、ロックProと名前がついているのが私の玄関につけられているものがスマートロックです。 ここからはこのスマートロックに対して操作をしていきます。

公式のGithubにこのデバイスに対するAPIの説明があるので、まずは確認してみます。 説明は状態を読み取るものと、制御を行うものにそれぞれ分かれているのですが、ここではまずデバイスの状態を読み取ってみましょう。

---
- hosts: localhost
  vars_files: vars/sign.yaml
  tasks:
    - name: Get Device Status
      ansible.builtin.shell: |
        curl -s 'https://api.switch-bot.com/v1.1/devices/{{ device_id }}/status' \
             -H 'Authorization: {{ token }}' \
             -H 'sign: {{ sign }}' \
             -H 't: {{ t }}' \
             -H 'nonce: {{ nonce }}' \
             -H 'Content-Type: application/json' \
             | jq -r '.'
      register: result
    - name: Display Device Status
      ansible.builtin.debug:
        var: result.stdout_lines

■ 実行結果

$ ansible-playbook -e "device_id=OOOOOOOOOOOOO" --ask-vault-pass GetDeviceStatus.yaml
Vault password: Ansible Vaultのパスワードを入力
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] **************************************************************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Get Device Status] ******************************************************************************************************************************************************************************************************************************************************************
changed: [localhost]

TASK [Display Device Status] **************************************************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "result.stdout_lines": [
        "{",
        "  \"statusCode\": 100,",
        "  \"body\": {",
        "    \"version\": \"V1.9\",",
        "    \"battery\": 87,",
        "    \"lockState\": \"locked\",",
        "    \"doorState\": \"closed\",",
        "    \"calibrate\": true,",
        "    \"deviceId\": \"OOOOOOOOOOOOO\",",
        "    \"deviceType\": \"Smart Lock Pro\",",
        "    \"hubDeviceId\": \"XXXXXXXXXXXX\"",
        "  },",
        "  \"message\": \"success\"",
        "}"
    ]
}

PLAY RECAP ********************************************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

上記の結果でも、IDに関してはマスクしております。結果から、扉は閉められている状態でかつ、鍵も掛かっていることが分かります。

今回は他のデバイスの状態を確認したくなった場合でも汎用的に使える様なPlaybookにしてみました。ansible-playbook実行時のエクストラ変数指定でお好きなデバイスのIDを指定することで、そのデバイスの状態を確認することができます。

例えば、カーテンの状態を確認するのであれば、こんな感じです。

■ 実行結果 その2

$ ansible-playbook -e "device_id=DDDDDDDDDDDDD" --ask-vault-pass GetDeviceStatus.yaml
Vault password:
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] **************************************************************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Get Device Status] ******************************************************************************************************************************************************************************************************************************************************************
changed: [localhost]

TASK [Display Device Status] **************************************************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "result.stdout_lines": [
        "{",
        "  \"statusCode\": 100,",
        "  \"body\": {",
        "    \"version\": \"V1.1\",",
        "    \"calibrate\": true,",
        "    \"group\": true,",
        "    \"moving\": false,",
        "    \"battery\": 35,",
        "    \"slidePosition\": 100,",
        "    \"deviceId\": \"DDDDDDDDDDDDD\",",
        "    \"deviceType\": \"Curtain3\",",
        "    \"hubDeviceId\": \"XXXXXXXXXXXX\"",
        "  },",
        "  \"message\": \"success\"",
        "}"
    ]
}

PLAY RECAP ********************************************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

おっと、カーテンは結構電池が消耗してますね・・・。年末で一回充電しなくては・・・。

バイスを制御する

さて、状態を読み取るだけでは面白くないので、ここからは制御してみましょう。

ちょうど先ほど、玄関の鍵が掛けられている様子が確認できましたので、この鍵をAnsibleから開けてみたいと思います。ゾクゾクしますね。

---
- hosts: localhost
  vars_files: vars/sign.yaml
  tasks:
    - name: Control Lock Device
      ansible.builtin.shell: |
        curl -s 'https://api.switch-bot.com/v1.1/devices/{{ device_id }}/commands' \
             -H 'Authorization: {{ token }}' \
             -H 'sign: {{ sign }}' \
             -H 't: {{ t }}' \
             -H 'nonce: {{ nonce }}' \
             -H 'Content-Type: application/json' \
             -d '{"command": "{{ lock_status }}", "parameter": "default", "commandType": "command", "deviceType": "Lock"}'
    - name: Pause for 10 seconds
      ansible.builtin.pause:
        seconds: 10
    - name: Get Device Status
      ansible.builtin.shell: |
        curl -s 'https://api.switch-bot.com/v1.1/devices/{{ device_id }}/status' \
             -H 'Authorization: {{ token }}' \
             -H 'sign: {{ sign }}' \
             -H 't: {{ t }}' \
             -H 'nonce: {{ nonce }}' \
             -H 'Content-Type: application/json' \
             | jq -r '.'
      register: result
    - name: Display Device Status
      ansible.builtin.debug:
        var: result.stdout_lines
      register: result

ここでも、エクストラ変数で実際の制御の内容を切り換えられるようにしています。スマートロックのデバイスの場合、「lock」または「unlock」のいずれかになりますので、それをansible-playbook実行時のエクストラ変数で指定します。

また、正常に解錠されたかも確認したいので、制御後は再びデバイスの状態を確認して表示させます。ただし、動作が完了しないうちに確認しても意味が無いので、10秒待機してからステータスを読み取っています。

■ 実行結果

$ ansible-playbook -e "device_id=OOOOOOOOOOOOO" -e "lock_status=unlock" --ask-vault-pass LockCTL.yaml
Vault password:
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] **************************************************************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Control Lock Device] ****************************************************************************************************************************************************************************************************************************************************************
changed: [localhost]

TASK [Pause for 10 seconds] ***************************************************************************************************************************************************************************************************************************************************************
Pausing for 10 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
ok: [localhost]

TASK [Get Device Status] ******************************************************************************************************************************************************************************************************************************************************************
changed: [localhost]

TASK [Display Device Status] **************************************************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "result.stdout_lines": [
        "{",
        "  \"statusCode\": 100,",
        "  \"body\": {",
        "    \"version\": \"V1.9\",",
        "    \"battery\": 87,",
        "    \"lockState\": \"unlocked\",",
        "    \"doorState\": \"closed\",",
        "    \"calibrate\": true,",
        "    \"deviceId\": \"OOOOOOOOOOOOO\",",
        "    \"deviceType\": \"Smart Lock Pro\",",
        "    \"hubDeviceId\": \"XXXXXXXXXXXX\"",
        "  },",
        "  \"message\": \"success\"",
        "}"
    ]
}

PLAY RECAP ********************************************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=5    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

無事に状態が「Unlocked」となり、解錠されている様子が確認できました。

まとめ

頑張った甲斐があって、なんとか15日の28時半で記事をアップすることができて、まずはホッとしております。 無事に当初の目的も果たすことができ、個人的には満足です。まぁ、Ansibleのアドカレって言うよりも、どっちかっていうとSwitchbotのアドカレっぽい記事になってしまっている気もしますが・・・。

さらに、無事といってもuriモジュールが想定通りに動かなくて何時間も時間を溶かしたり、まとめを書いてる段階においてブラウザが死んで記事も書き直しになったりと、、、事故がなくもなかったのですが、まぁ、終わり良ければ全てよし!の精神で生きていこうと思います。まさか、冒頭でいじった@habuka036 さんと同じ事態に陥るとは・・・。

今回の内容は、正直無理にAnsibleを絡めないでも良いのでは・・・?と思う部分が無きにしも非ずですが、それでも例えばAWXのワークフローを組み合わせることで、スマートホームの制御がより自動化されて捗る・・・なんてこともあるかもしれませんね。

明日のアドカレ当番は@naka-shin1さんです!お楽しみに!!

*1:署名の作成についての詳細は公式のGithubリポジトリを参照してください。