😸

【猫監視システムへの道】2.SwitchBotをPCから操作する

2023/08/21に公開

自宅のスマート家電を一括操作したい

前提

我が家にあるスマート家電と、スマート家電から操作している家電の一覧です。

メーカー 商品名/型番 備考
Amazon Amazon echo 第3世代
SwitchBot Hub mini
SwitchBot 湿温度計
xydled LEDスポットライトGU10 RGBW RGB調光対応のLEDライト。リビング用。
SONY Bravia KJ-43X8000H ほぼYouTubeを見るだけに使っているTV。リビング用。
ダイキン AN22XEBKS-W エアコン。リビング用と寝室用の2台。
ECOVACS DEEBOT N8 PRO+ ロボット掃除機
Tp-link Tapo C210 V2 ネットワークカメラ。New!!

この中では、Amazon echoが一番使っていません。
SwitchBotとの連携ができない期間が長かったのと、
Alexaに「すみません。よくわかりませんでした」と返されるたびにイラっとして
今ではほとんど使わなくなってしまいました。

寝室の照明が赤外線リモコン対応ではないので、
おそらく他の家で一番使われているであろう「Alexaおはよう(おやすみ)」すら
我が家では出番がありません。

ちなみに、猫のためにリビングのエアコンは24h稼働ですが、
私自身はエアコンの28度も寒いと感じるので、寝室のエアコンはほぼ使いません。

そのような状況なので、SwitchBotの操作だけなら
スマホのSwitchBotアプリや、(たぶん)Alexaでも十分なのですが、
カメラが届く前に、まずはPCからSwitchBotを制御するツールを作ってみる事にしました。

実現したいこと

PC上で下記を実現したい。

  • SwitchBotの湿温度計から、5分毎に温度と湿度を取得
  • エアコンのON/OFF操作
  • TVのON/OFF操作、音量の増減操作
  • 照明のON/OFF操作、明るさの増減操作
  • これらを1つのGUI上にまとめる

参考サイト

環境

  • Windows11
  • Python3.8.9(64bit)
  • SwitchBot Appのバージョン: 7.3
  • SwitchBot Hub miniのファームウエアバージョン: V5.4-3.8

事前準備

  1. Pythonのインストール
    別件でインストール済だった、Python3.8.9(64bit)をそのまま使用。

  2. Pythonのライブラリをインストール

pip install requests==2.31.0
  1. トークンを取得
    参考にしたサイトと、若干表記が異なっていた。
    ※アプリのバージョンの違いによるものだと思われる。
    (1)SwitchBot Appを起動。
    (2)「プロフィール」の「設定」を開く
    (3)「アプリバージョン」部分を10回タップ。
    (4)「デベロッパーオプション」が表示されるので開く。
    (5)トークンを取得。

  2. SwitchBotのアプリで、エアコンや照明操作用のデバイスを登録
    (1)SwitchBot Appを起動。
    (2)「Home」の「+」
     →「デバイスの追加」
     →「赤外線リモコン」
     →エアコン、TV、照明などを選択してリモコンを学習させる。
    (3)登録したデバイスの動作テストを行う。

  3. デバイスIDの取得
    こんな感じのスクリプトを書いて、コマンドプロンプトから実行。

get_device_list.py
# coding: utf-8
import json

import requests

#-------------------------------
# SwitchBot Hub miniとの接続設定
#-------------------------------
TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 
API_HOST = 'https://api.switch-bot.com'


class SwitchBot:
    def __init__(self, token, host):
        self.token = token
        self.host  = host
        self.devlist_url = f'{self.host}/v1.0/devices'
        self.headers = {
            'Authorization': self.token,
            'Content-Type': 'application/json; charset=utf8'
        }

    def print_device_list(self):
        """
        デバイス一覧を出力する
        """
        devices = self.get_device_list()

        # SwitchBot関連のデバイスリストを取得する。
        self.pub_device_list = devices.get('deviceList')
        # 赤外線操作対象のデバイスリストを取得する。
        self.ir_device_list  = devices.get('infraredRemoteList')

        # 湿温度計の情報を取得
        print('デバイスID一覧')
        for device in self.pub_device_list:
            print(device)

        print('\n赤外線リモコン操作ID一覧')
        # 赤外線操作対象
        for device in self.ir_device_list:
            print(device)

    def get_device_list(self):
        '''
        デバイスIDの一覧を取得する
        '''
        try:
            return self.get_request(self.devlist_url)['body']
        except:
            print('デバイスIDを検出できませんでした')
            return None

    def get_request(self, url):
        try:
            res = requests.get(url, headers=self.headers, timeout=2.0)
            data = res.json()
            if data['message'] == 'success':
                return res.json()
            else:
                print(data['statusCode'])
                print(data['message'])
        except Timeout:
            print('コマンド送信がタイムアウトしました')
        except Exception as err:
            print(err)
        return {}


if __name__ == '__main__':
    sb = SwitchBot(TOKEN, API_HOST)
    sb.print_device_list()

結果
※デバイスID部分は置き換えています。

デバイスID一覧
{'deviceId': 'ハブミニのデバイスID', 'deviceName': 'ハブミニ 30', 'deviceType': 'Hub Mini', 'enableCloudService': False, 'hubDeviceId': ''}
{'deviceId': '温湿度計のデバイスID', 'deviceName': '温湿度計 4D', 'deviceType': 'Meter', 'enableCloudService': True, 'hubDeviceId': 'ハブミニのデバイスID'}

赤外線リモコン操作ID一覧
{'deviceId': '赤外線リモコン操作ID', 'deviceName': 'リビングライト', 'remoteType': 'Light', 'hubDeviceId': 'ハブミニのデバイスID'}
{'deviceId': '赤外線リモコン操作ID', 'deviceName': 'テレビ', 'remoteType': 'TV', 'hubDeviceId': 'ハブミニのデバイスID'}
{'deviceId': '赤外線リモコン操作ID', 'deviceName': 'エアコン冷房28度', 'remoteType': 'DIY Air Conditioner', 'hubDeviceId': 'ハブミニのデバイスID'}
{'deviceId': '赤外線リモコン操作ID', 'deviceName': 'エアコンドライ', 'remoteType': 'DIY Air Conditioner', 'hubDeviceId': 'ハブミニのデバイスID'}
{'deviceId': '赤外線リモコン操作ID', 'deviceName': 'エアコン暖房25度', 'remoteType': 'DIY Air Conditioner', 'hubDeviceId': 'ハブミニのデバイスID'}
{'deviceId': '赤外線リモコン操作ID', 'deviceName': 'Alexa', 'remoteType': 'Others', 'hubDeviceId': 'ハブミニのデバイスID'}

SwitchBot制御用GUI作成

ファイル構成

ファイル名 用途
main.py GUIデザイン部分
util\switch_bot.py SwitchBotとの通信機能
util\sb_command.py SwitchBot制御用コマンド

コマンド定義ファイル

※デバイスID部分は要置き換え。

util\sb_command.py
# coding: utf-8
#-------------------------------
# デバイスID
#-------------------------------
DIC_DIV_ID = {
    'Hub Mini': 'ハブミニのデバイスID',
    'Meter'   : '湿温度計のデバイスID',
}

#-------------------------------
# 基本コマンド設定
#-------------------------------
# 電源ON用コマンド。TV、照明、エアコン等で共通。
CMD_TURN_ON = {
    'command'     : 'turnOn',
    'parameter'   : 'default',
    'commandType' : 'command'
}

# 電源ON用コマンド。TV、照明、エアコン等で共通。
CMD_TURN_OFF = {
    'command'     : 'turnOff',
    'parameter'   : 'default',
    'commandType' : 'command'
}

# エアコン設定
# 下記説明を元にしたコマンドを作成すると、160(No this command)が返るため、
# TVや照明操作時と同じコマンドを送信している。
# https://github.com/OpenWonderLabs/SwitchBotAPI#infrared-remote-device-example
# https://github.com/OpenWonderLabs/SwitchBotAPI#command-set-for-virtual-infrared-remote-devices
CMD_AIR_CON_COOL28 = CMD_TURN_ON
CMD_AIR_CON_DRY    = CMD_TURN_ON
CMD_AIR_CON_HOT25  = CMD_TURN_ON
CMD_AIR_CON_OFF    = CMD_TURN_OFF

# 照明操作
CMD_LIGHT_UP = {
    'command'    : 'brightnessUp',
    'parameter'  : 'default',
    'commandType': 'command'
}
CMD_LIGHT_DOWN = {
    'command'    : 'brightnessDown',
    'parameter'  : 'default',
    'commandType': 'command'
}

# TV操作
CMD_TV_VUP = {
    'command'    : 'volumeAdd',
    'parameter'  : 'default',
    'commandType': 'command'
}
CMD_TV_VDOWN = {
    'command'    : 'volumeSub',
    'parameter'  : 'default',
    'commandType': 'command'
}

#-------------------------------
# ボタンクリック時の引数と、デバイスID、コマンドを紐付ける
#-------------------------------
DIC_CMD = {
    # エアコン
    'air_cool28': ('赤外線リモコン操作ID', CMD_AIR_CON_COOL28),
    'air_dry'   : ('赤外線リモコン操作ID', CMD_AIR_CON_DRY),
    'air_hot25' : ('赤外線リモコン操作ID', CMD_AIR_CON_HOT25),
    'air_off'   : ('赤外線リモコン操作ID', CMD_AIR_CON_OFF),
    # 照明操作
    'light_on'  : ('赤外線リモコン操作ID', CMD_TURN_ON),
    'light_off' : ('赤外線リモコン操作ID', CMD_TURN_OFF),
    'light_up'  : ('赤外線リモコン操作ID', CMD_LIGHT_UP),
    'light_down': ('赤外線リモコン操作ID', CMD_LIGHT_DOWN),
    # TV操作
    'tv_on'     : ('赤外線リモコン操作ID', CMD_TURN_ON),
    'tv_off'    : ('赤外線リモコン操作ID', CMD_TURN_OFF),
    'tv_vup'    : ('赤外線リモコン操作ID', CMD_TV_VUP),
    'tv_vdown'  : ('赤外線リモコン操作ID', CMD_TV_VDOWN)
}

エアコンのコマンドに関しては、SwitchBot APIの赤外線操作コマンドの説明や、SwitchBot APIの記述例だと下記のようになっていますが、

{
    "command": "setAll",
    "parameter": "26,1,3,on",
    "commandType": "command"
}

私の環境ではレスポンスコード160(No this command)が返りました。

SwitchBot制御部分

前出のget_device_list.pyに手を加えたもの。

util\switch_bot.py
# coding: utf-8
import json

import requests


import util.sb_command as cmd


class SwitchBot():
    def __init__(self, token, host):
        self.token = token
        self.host  = host
        self.devlist_url = f'{self.host}/v1.0/devices'
        self.headers = {
            'Authorization': self.token,
            'Content-Type': 'application/json; charset=utf8'
        }

    def check_meter(self):
        '''
        SwitchBotの湿温度計チェック
        '''
        try:
            status = self.get_device_status(cmd.DIC_DIV_ID.get('Meter'))
            return ('{0:4.1f}'.format(status.get('temperature')),
                    '{0:4.1f}'.format(status.get('humidity')))
        except Exception as e:
            print(f"Request error: {e}")
            return (None, None)

    def send_ir(self, key):
        device_id = cmd.DIC_CMD.get(key)[0]
        params    = cmd.DIC_CMD.get(key)[1]
        url = f'{self.host}/v1.0/devices/{device_id}/commands'
        self.post_request(url, params)

    def get_device_status(self, device_id):
        '''
        Switchbotデバイスのステータスを取得する。
        '''
        url = f"{self.host}/v1.0/devices/{device_id}/status"
        try:
            r = requests.get(url, headers=self.headers)
            r.raise_for_status()
        except HTTPError as e:
            raise HTTPError(f"HTTP error: {e}")
        except RequestException as e:
            raise RequestException(e)
        else:
            return r.json()["body"]

    def post_request(self, url, params):
        try:
            print(json.dumps(params))
            res = requests.post(url, data=json.dumps(params), headers=self.headers)
            data = res.json()
            if data['message'] == 'success':
                print('-> Success')
                return res.json()
            else:
                print(data['statusCode'])
                print(data['message'])
        except Timeout:
            print('コマンド送信がタイムアウトしました')
        except Exception as err:
            print(err)
        return {}

GUI

main.py
# coding: utf-8
import datetime
import json
import os
import sys
import time
import tkinter as tk
import tkinter.ttk as ttk

import requests

import util.switch_bot ad SwitchBot
import util.sb_command as cmd


#-------------------------------
# フォント(DSEG v046を使用)
#-------------------------------
# https://www.keshikan.net/fonts.html
FONT_DIG = 'DSEG14 Classic Mini'

#-------------------------------
# SwitchBot Hub miniとの接続設定
#-------------------------------
SB_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
SB_API_HOST = 'https://api.switch-bot.com'


class Application(tk.Frame):
    def __init__(self, master = None):
        super().__init__(master)

        self.sb = SwitchBot(SB_TOKEN, SB_API_HOST)
        self.temperature, self.humidity = self.sb.check_meter()

        #-------------------------------
        # Window作成
        #-------------------------------
        self.pack()
        master.title('Smart Home Controller')
        master.geometry('800x180')
        master.resizable(False, False)         # リサイズ不可に設定

        #-------------------------------
        # Tabを定義
        #-------------------------------
        frame_top = tk.Frame(master, width=800, height=180)
        frame_top.propagate(False)
        frame_top.place(x=0, y=0)

        # タブのインスタンスを作成
        nb = ttk.Notebook(master, width=800, height=125)
        nb.place(x=0, y=40)
        tab1 = tk.Frame(nb)

        # タブに表示する文字列の設定
        nb.add(tab1, text='リビング', padding=4)

        #-------------------------------
        # Tab1のFrameを定義
        #-------------------------------
        # tab1
        frame1 = tk.Frame(tab1, width=130, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
        frame2 = tk.Frame(tab1, width=250, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
        frame3 = tk.Frame(tab1, width=180, height=95, borderwidth=1, relief='solid', padx=5, pady=3)
        frame4 = tk.Frame(tab1, width=180, height=95, borderwidth=1, relief='solid', padx=5, pady=3)

        # Frameサイズを固定
        frame1.propagate(False)
        frame2.propagate(False)
        frame3.propagate(False)
        frame4.propagate(False)

        # Frameを配置
        frame1.place(x=  0, y=  0)
        frame2.place(x=145, y=  0)
        frame3.place(x=410, y=  0)
        frame4.place(x=605, y=  0)

        #-------------------------------
        # Tab1のWidget配置
        #-------------------------------
        # frame_top: 日付と時刻(YYYY/MM/DD hh:mm)
        dt = datetime.datetime.now()
        self.label_ta = tk.Label(frame_top,
                                 text=self._clock(),
                                 font=('メイリオ', 14, 'bold'))
        self.label_ta.place(x=0, y=0)
        # 繰り返し処理(1000ms=1secごと)
        self.label_ta.after(1000, self._update_clock)

        # frame1: 湿温度計の情報を表示
        label_1a = tk.Label(frame1, text='湿温度計', font=('メイリオ', 12))
        label_1a.place(x=0, y=0)

        self.label_1b = tk.Label(frame1,
                                 text=self.temperature,
                                 fg='green',
                                 bg='lightgray',
                                 font=(FONT_DIG, 12))
        self.label_1b.place(x=20, y=30)

        label_1c = tk.Label(frame1, text='°C'.format(28.4), font=('メイリオ', 10))
        label_1c.place(x=70, y=30)
        self.label_1d = tk.Label(frame1,
                                 text=self.humidity,
                                 fg='green',
                                 bg='lightgray',
                                 font=(FONT_DIG, 12))
        self.label_1d.place(x=20, y=60)
        # 繰り返し処理(5分ごと)
        self.label_1d.after(5 * 60 * 1000, self._update_meter)
        label_1e = tk.Label(frame1, text='%'.format(81.2), font=('メイリオ', 10))
        label_1e.place(x=70, y=60)

        # frame2: エアコン操作
        label_2a = tk.Label(frame2, text='エアコン', font=('メイリオ', 12))
        label_2a.place(x=0, y=0)

        button_2a = tk.Button(frame2, text='冷房28度', width='8', command=lambda:self._click_btn_sw('air_cool28'))
        button_2a.place(x=20, y=30)
        button_2b = tk.Button(frame2, text='ドライ', width='8', command=lambda:self._click_btn_sw('air_dry'))
        button_2b.place(x=90, y=30)
        button_2b = tk.Button(frame2, text='暖房25度', width='8', command=lambda:self._click_btn_sw('air_hot25'))
        button_2b.place(x=160, y=30)
        button_2c = tk.Button(frame2, text='OFF', width='8', command=lambda:self._click_btn_sw('air_off'), bg='red')
        button_2c.place(x=20, y=60)

        # frame3: リビングの照明操作
        label_3a = tk.Label(frame3, text='照明', font=('メイリオ', 12))
        label_3a.place(x=0, y=0)

        button_3a = tk.Button(frame3, text='ON', width='8', command=lambda:self._click_btn_sw('light_on'))
        button_3a.place(x=20, y=30)
        button_3b = tk.Button(frame3, text='明るさ▲', width='8', command=lambda:self._click_btn_sw('light_up'))
        button_3b.place(x=90, y=30)
        button_3c = tk.Button(frame3, text='OFF', width='8', command=lambda:self._click_btn_sw('light_off'), bg='red')
        button_3c.place(x=20, y=60)
        button_3d = tk.Button(frame3, text='明るさ▼', width='8', command=lambda:self._click_btn_sw('light_down'))
        button_3d.place(x=90, y=60)

        # frame4: TV操作
        label_4a = tk.Label(frame4, text='TV', font=('メイリオ', 12))
        label_4a.place(x=0, y=0)

        button_4a = tk.Button(frame4, text='ON', width='8', command=lambda:self._click_btn_sw('tv_on'))
        button_4a.place(x=20, y=30)
        button_4b = tk.Button(frame4, text='Vol▲', width='8', command=lambda:self._click_btn_sw('tv_vup'))
        button_4b.place(x=90, y=30)
        button_4b = tk.Button(frame4, text='OFF', width='8', command=lambda:self._click_btn_sw('tv_off'), bg='red')
        button_4b.place(x=20, y=60)
        button_4d = tk.Button(frame4, text='Vol▼', width='8', command=lambda:self._click_btn_sw('tv_vdown'))
        button_4d.place(x=90, y=60)

    def _click_btn_sw(self, key=None):
        """
        SwitchBot操作用ボタンのクリック時に呼ばれる関数。
        """
        print('push {} botton'.format(key))
        self.sb.send_ir(key)

    def _clock(self):
        '''
        日付と時刻(YYYY/MM/DD hh:mm:ss)
        '''
        dt = datetime.datetime.now()
        return dt.strftime('%Y/%m/%d(%a) %H:%M:%S')

    def _update_clock(self):
        '''
        日付と時刻を1秒ごとに更新する
        '''
        self.label_ta.configure(text=self._clock())
        self.label_ta.after(1000, self._update_clock)

    def _update_meter(self):
        '''
        湿温度計を5分ごとに更新する
        '''
        temp, hum = self.sb.check_meter()
        self.label_1b.configure(text=temp)
        self.label_1d.configure(text=hum)
        print(f'湿温度計を更新しました。{temp}{hum}%')
        self.label_1d.after(5 * 60 * 1000, self._update_meter)


if __name__ == "__main__":
    root = tk.Tk()
    app = Application(master = root)
    app.mainloop()

結果

実現したいことがすべて実現できました!
完成した画面

Discussion