├── setup.cfg ├── switchbotpy ├── __init__.py ├── switchbot_util.py ├── switchbot_timer.py └── switchbot.py ├── examples ├── scan_example.py ├── press_example.py ├── timers_example.py └── settings_example.py ├── LICENSE ├── setup.py ├── .gitignore └── README.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /switchbotpy/__init__.py: -------------------------------------------------------------------------------- 1 | from switchbotpy.switchbot import Scanner, Bot 2 | from switchbotpy.switchbot_timer import StandardTimer, Action, Mode 3 | from switchbotpy.switchbot_util import SwitchbotError, ActionStatus -------------------------------------------------------------------------------- /examples/scan_example.py: -------------------------------------------------------------------------------- 1 | from switchbotpy import Scanner 2 | 3 | def main(): 4 | """scan for switchbots example""" 5 | 6 | scanner = Scanner() 7 | mac_addresses = scanner.scan() 8 | 9 | if not mac_addresses: 10 | print("No switchbot found.") 11 | return 12 | 13 | print("Switchbot mac addresses:") 14 | for mac in mac_addresses: 15 | print(mac) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /examples/press_example.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from switchbotpy import Bot 4 | 5 | def main(config): 6 | """press switchbot example""" 7 | 8 | # initialize bot 9 | bot = Bot(bot_id=0, mac=config.mac, name="bot0") 10 | if config.password: 11 | bot.encrypted(password=config.password) 12 | 13 | # execute press command 14 | bot.press() 15 | 16 | 17 | 18 | if __name__ == "__main__": 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument("--mac", help="mac address of switchbot") 21 | parser.add_argument("--password", help="password of switchbot") 22 | args = parser.parse_args() 23 | if not args.mac: 24 | args.mac = input("Enter mac address of switchbot: ") 25 | 26 | main(config=args) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nicolas Küchler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='switchbotpy', 8 | packages=['switchbotpy'], 9 | version='0.1.7', 10 | license='MIT', 11 | description='An API for Switchbots that allows to control actions, settings and timers (also password protected)', 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | author='Nicolas Küchler', 15 | author_email='nico.kuechler@protonmail.com', 16 | url='https://github.com/RoButton/switchbotpy', 17 | download_url='https://github.com/RoButton/switchbotpy/archive/v_017.tar.gz', 18 | keywords=['Switchbot', 'Ble', 'Button', 'Actions', 'Settings', 'Timers'], 19 | install_requires=['pygatt', 'pexpect'], 20 | classifiers=[ 21 | 'Development Status :: 3 - Alpha', 22 | 'Intended Audience :: Developers', 23 | 'Topic :: Software Development :: Build Tools', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python :: 3.6', 26 | 'Programming Language :: Python :: 3.7', 27 | 'Programming Language :: Python :: 3.8', 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | .vscode/settings.json 106 | -------------------------------------------------------------------------------- /switchbotpy/switchbot_util.py: -------------------------------------------------------------------------------- 1 | import queue 2 | from enum import Enum 3 | 4 | notification_queue = queue.Queue() 5 | 6 | def handle_notification(handle: int, value: bytes): 7 | """ 8 | handle: integer, characteristic read handle the data was received on 9 | value: bytearray, the data returned in the notification 10 | """ 11 | notification_queue.put((handle, value)) 12 | 13 | class ActionStatus(Enum): 14 | complete = 1 15 | device_busy = 3 16 | device_unreachable = 11 17 | device_encrypted = 7 18 | device_unencrypted = 8 19 | wrong_password = 9 20 | 21 | unable_resp = 254 22 | unable_connect = 255 23 | 24 | def msg(self): 25 | if self == ActionStatus.complete: 26 | msg = "action complete" 27 | elif self == ActionStatus.device_busy: 28 | msg = "switchbot is busy" 29 | elif self == ActionStatus.device_unreachable: 30 | msg = "switchbot is unreachable" 31 | elif self == ActionStatus.device_encrypted: 32 | msg = "switchbot is encrypted" 33 | elif self == ActionStatus.device_unencrypted: 34 | msg = "switchbot is unencrypted" 35 | elif self == ActionStatus.wrong_password: 36 | msg = "switchbot password is wrong" 37 | elif self == ActionStatus.unable_resp: 38 | msg = "switchbot does not respond" 39 | elif self == ActionStatus.unable_resp: 40 | msg = "switchbot unable to connect" 41 | else: 42 | raise ValueError("unknown action status: " + str(self)) 43 | 44 | return msg 45 | 46 | 47 | class SwitchbotError(Exception): 48 | def __init__(self, message, switchbot_action_status:ActionStatus=None): 49 | super().__init__(message) 50 | self.switchbot_action_status = switchbot_action_status 51 | -------------------------------------------------------------------------------- /examples/timers_example.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from switchbotpy import Bot 4 | from switchbotpy import StandardTimer 5 | from switchbotpy import Action 6 | 7 | def main(config): 8 | """timers switchbot example""" 9 | 10 | # initialize bot 11 | bot = Bot(bot_id=0, mac=config.mac, name="bot0") 12 | if config.password: 13 | bot.encrypted(password=config.password) 14 | 15 | print("get timers...") 16 | timers = bot.get_timers() 17 | for i, timer in enumerate(timers): 18 | timer_dict = timer.to_dict(timer_id=i) 19 | print(" timer: ", timer_dict["id"]) 20 | print(" mode: ", timer_dict["mode"]) 21 | print(" action: ", timer_dict["action"]) 22 | print(" enabled: ", timer_dict["enabled"]) 23 | print(" weekdays: ", timer_dict["weekdays"]) 24 | print(" hour: ", timer_dict["hour"]) 25 | print(" minute: ", timer_dict["min"]) 26 | 27 | 28 | print("set timers...") 29 | timer = StandardTimer(action=Action["press"], # Action["turn_on"], Action["turn_off"] 30 | enabled=True, 31 | weekdays=[1, 2, 5], 32 | hour=15, 33 | min=30) 34 | try: 35 | t_id = int(input("Enter timer id: [0,5): ")) 36 | if t_id >= 5: 37 | raise ValueError() 38 | 39 | if t_id >= len(timers): 40 | timers.append(timer) 41 | else: 42 | timers[t_id] = timer 43 | 44 | bot.set_timers(timers) 45 | print(" updated timers") 46 | except ValueError: 47 | print(" skip updating timers") 48 | 49 | if config.clear: 50 | print("clearing all timers from switchbot") 51 | bot.set_timers([]) 52 | 53 | 54 | if __name__ == "__main__": 55 | parser = argparse.ArgumentParser() 56 | parser.add_argument("--mac", help="mac address of switchbot") 57 | parser.add_argument("--password", help="password of switchbot") 58 | parser.add_argument("--clear", help="clear all timers in the end") 59 | 60 | args = parser.parse_args() 61 | if not args.mac: 62 | args.mac = input("Enter mac address of switchbot: ") 63 | 64 | main(config=args) 65 | -------------------------------------------------------------------------------- /examples/settings_example.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from switchbotpy import Bot 4 | 5 | def main(config): 6 | """settings switchbot example""" 7 | 8 | # initialize bot 9 | bot = Bot(bot_id=0, mac=config.mac, name="bot0") 10 | if config.password: 11 | bot.encrypted(password=config.password) 12 | 13 | # execute get settings command 14 | print("get settings...") 15 | settings = bot.get_settings() 16 | print(" battery: ", settings["battery"]) 17 | print(" firmware: ", settings["firmware"]) 18 | print(" hold seconds: ", settings["hold_seconds"]) 19 | print(" timer count: ", settings["n_timers"]) 20 | print(" dual state mode: ", settings["dual_state_mode"]) 21 | print(" inverse direction: ", settings["inverse_direction"]) 22 | 23 | # execute set settings commands 24 | print("set settings...") 25 | 26 | # adjust hold time 27 | if config.hold: 28 | bot.set_hold_time(sec=config.hold) 29 | 30 | # adjust mode and inverse 31 | if config.mode or config.inverse: 32 | if not config.mode: 33 | dual = settings["dual_state_mode"] 34 | elif config.mode == 'standard': 35 | dual = False 36 | elif config.mode == 'dual': 37 | dual = True 38 | else: 39 | raise ValueError("Unknown config.mode: ", config.mode) 40 | 41 | if not config.inverse: 42 | config.inverse = settings["inverse_direction"] 43 | 44 | bot.set_mode(dual_state=dual, inverse=config.inverse) 45 | 46 | 47 | 48 | if __name__ == "__main__": 49 | parser = argparse.ArgumentParser() 50 | parser.add_argument("--mac", help="mac address of switchbot") 51 | parser.add_argument("--password", help="password of switchbot") 52 | parser.add_argument("--hold", help="press hold seconds of switchbot", type=int) 53 | mode_choices = ['standard', 'dual'] 54 | parser.add_argument("--mode", help="mode of switchbot", choices=mode_choices) 55 | parser.add_argument("--inverse", help="inverse state", nargs=None) 56 | args = parser.parse_args() 57 | if not args.mac: 58 | args.mac = input("Enter mac address of switchbot: ") 59 | 60 | if not args.hold: 61 | try: 62 | args.hold = int(input("Enter hold seconds (skip with enter): ")) 63 | except ValueError: 64 | pass 65 | if not args.mode: 66 | mode = input("Enter switchbot mode [standard/dual] (skip with enter): ") 67 | if mode in mode_choices: 68 | args.mode = mode 69 | 70 | if not args.inverse: 71 | inverse = input("Inverse? [y/n] (skip with enter): ") 72 | inverse = inverse.lower().strip() 73 | if inverse == 'y': 74 | args.inverse = True 75 | elif inverse == 'n': 76 | args.inverse = False 77 | 78 | main(config=args) -------------------------------------------------------------------------------- /switchbotpy/switchbot_timer.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from abc import ABC 3 | from typing import List 4 | 5 | 6 | def parse_timer_cmd(val: bytes): 7 | 8 | num_timer = val[1] 9 | weekdays = _from_byte_to_iso_weekdays(val[3]) 10 | hour = val[4] 11 | minutes = val[5] 12 | 13 | interval_mode = val[6] & 15 # 15 = 00001111 in binary and interval mode is only in second part of the byte 14 | action = val[7] & 15 # action mode is only in second part of the byte 15 | interval_timer_sum = val[8] 16 | interval_hour = val[9] 17 | interval_min = val[10] 18 | 19 | enabled = val[3] != 0 20 | 21 | if not enabled: 22 | # if a timer is disabled, then the repeating pattern is stored in the first part of the interval mode and the action mode 23 | repeat = (val[6] & 240) | ((val[7] & 240) >> 4) 24 | weekdays = _from_byte_to_iso_weekdays(repeat) 25 | 26 | if not enabled and hour == 0 and minutes == 0 and interval_timer_sum == 0 and interval_hour == 0 and interval_min == 0: 27 | timer = None # timer not set 28 | elif interval_mode: 29 | timer = IntervalTimer(enabled=enabled, 30 | mode=interval_mode, 31 | action=Action(action), 32 | timer_sum=interval_timer_sum, 33 | hour=interval_hour, 34 | min=interval_min) 35 | else: 36 | timer = StandardTimer(enabled=enabled, weekdays=weekdays, hour=hour, min=minutes, action=Action(action)) 37 | 38 | return timer, num_timer 39 | 40 | def delete_timer_cmd(idx: int, num_timer: int): 41 | 42 | # \x03 for 0'th timer, \x13 for 1st timer, \x23 for 2nd timer 43 | cmd = _to_byte(idx*16+3) 44 | 45 | # \x01 for 1 timer, \x02 for 2 timers, ... , \x05 for 5 timers 46 | cmd += _to_byte(num_timer) 47 | 48 | # filler repeat hour min mode action interval_timer_sum interval_hour interval_min 49 | cmd += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00' 50 | 51 | return cmd 52 | 53 | 54 | 55 | def _to_byte(value: int): 56 | return value.to_bytes(1, byteorder='big') 57 | 58 | def _from_iso_weekdays_to_byte(weekdays: List[int]): 59 | 60 | val = 0 61 | for day in weekdays: 62 | val += 2 ** (day-1) 63 | 64 | if val == 0: 65 | val = 128 66 | 67 | return _to_byte(val) 68 | 69 | def _from_byte_to_iso_weekdays(val: bytes): 70 | weekdays = [] 71 | if val & 1: weekdays += [1] # Monday 72 | if val & 2: weekdays += [2] # Tuesday 73 | if val & 4: weekdays += [3] # Wednesday 74 | if val & 8: weekdays += [4] # Thursday 75 | if val & 16: weekdays += [5] # Friday 76 | if val & 32: weekdays += [6] # Saturday 77 | if val & 64: weekdays += [7] # Sunday 78 | # if val & 128: no repeat 79 | 80 | return weekdays 81 | 82 | class Action(Enum): 83 | press = 0 84 | turn_on = 1 85 | turn_off = 2 86 | 87 | class Mode(Enum): 88 | standard = 0 89 | interval = 1 # TODO [nku] not sure, needs to be verified 90 | 91 | 92 | class BaseTimer(ABC): 93 | 94 | def __init__(self, enabled:bool=None, weekdays:List[int]=None, hour:int=None, min:int=None, mode:Mode=None, action:Action=None, interval_timer_sum:int=None, interval_hour:int=None, interval_min:int=None): 95 | self.enabled = enabled 96 | self.weekdays = weekdays 97 | self.hour = hour if hour else 0 98 | self.min = min if min else 0 99 | self.mode = mode 100 | self.action = action 101 | self.interval_timer_sum = interval_timer_sum if interval_timer_sum else 0 102 | self.interval_hour = interval_hour if interval_hour else 0 103 | self.interval_min = interval_min if interval_min else 0 104 | 105 | super().__init__() 106 | 107 | 108 | def to_cmd(self, idx: int, num_timer: int): 109 | 110 | if idx < 0 or idx >= num_timer or num_timer > 5: 111 | raise ValueError("Illegal Argument: Support for max 5 timers and idx must be < num_timer") 112 | 113 | # \x03 for 0'th timer, \x13 for 1st timer, \x23 for 2nd timer 114 | cmd = _to_byte(idx*16+3) 115 | 116 | # \x01 for 1 timer, \x02 for 2 timers, ... , \x05 for 5 timers 117 | cmd += _to_byte(num_timer) 118 | 119 | # filler 120 | cmd += b'\x00' 121 | 122 | # byte[0] = No Repeat, byte[1] = Sunday, byte[2] = Saturday, ... byte[7] = Monday 123 | repeat = _from_iso_weekdays_to_byte(self.weekdays) 124 | if self.enabled: 125 | cmd += repeat 126 | else: 127 | cmd += b'\x00' 128 | 129 | cmd += _to_byte(self.hour) 130 | cmd += _to_byte(self.min) 131 | 132 | mode_b = _to_byte(self.mode.value) 133 | if not self.enabled: 134 | # if timer is not enabled, store the first 4 bits (no_rep, sun, sat, fri) 135 | # of the repeating pattern in the top 4 bits of the action 136 | mode_b = _to_byte(ord(mode_b) | (ord(repeat) & 240)) 137 | 138 | cmd += mode_b 139 | 140 | action_b = _to_byte(self.action.value) 141 | if not self.enabled: 142 | # if timer is not enabled, store the last 4 bits (mon, tue, wed, thu) 143 | # of the repeating pattern in the top 4 bits of the action 144 | action_b = _to_byte(ord(action_b) | ((ord(repeat) & 15) << 4)) 145 | 146 | cmd += action_b 147 | 148 | cmd += _to_byte(self.interval_timer_sum) 149 | cmd += _to_byte(self.interval_hour) 150 | cmd += _to_byte(self.interval_min) 151 | 152 | return cmd 153 | 154 | def to_dict(self, timer_id=None): 155 | raise NotImplementedError() 156 | 157 | 158 | 159 | class StandardTimer(BaseTimer): 160 | 161 | def __init__(self, enabled: bool, weekdays: List[int], hour: int, min: int, action: Action): 162 | BaseTimer.__init__(self, mode=Mode.standard, action=action, enabled=enabled, weekdays=weekdays, hour=hour, min=min) 163 | 164 | def to_dict(self, timer_id=None): 165 | d = {} 166 | if timer_id is not None: 167 | d['id'] = timer_id 168 | d['mode'] = self.mode.name 169 | d['action'] = self.action.name 170 | d['enabled'] = self.enabled 171 | d['weekdays'] = self.weekdays 172 | d['hour'] = self.hour 173 | d['min'] = self.min 174 | return d 175 | 176 | 177 | class IntervalTimer(BaseTimer): 178 | def __init__(self, enabled: bool, mode: Mode, action: Action, timer_sum: int, hour: int, min: int): 179 | BaseTimer.__init__(self, enabled=enabled, mode=mode, action=action, interval_timer_sum=timer_sum, interval_hour=hour, interval_min=min) 180 | 181 | def to_dict(self, timer_id=None): 182 | d = {} 183 | if timer_id is not None: 184 | d['id'] = timer_id 185 | d['mode'] = self.mode.name 186 | d['action'] = self.action.name 187 | d['enabled'] = self.enabled 188 | d['timer_sum'] = self.interval_timer_sum 189 | d['hour'] = self.interval_hour 190 | d['min'] = self.interval_min 191 | return d -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switchbot API 2 | 3 | A Python API for [SwitchBots](https://www.switch-bot.com/) that allows to control actions, settings and timers. 4 | 5 | The setup is tested on a RaspberryPi 3 with the Raspbian Buster OS in combination with Switchbots running firmware 4.4 and 4.5 6 | 7 | ## FAQ 8 | 9 | Note that if you observe the following error: `Can't init device hci0: Connection timed out (110)` while running either of the APIs. Update all packages [see these steps](https://github.com/RoButton/switchbotpy/issues/13#issuecomment-617072613). 10 | 11 | ## Usage 12 | 13 | The examples folder contains demonstrations how to scan for switchbots, press the switchbot, read and set settings (battery, firmware, hold time, etc.), read and set timers. 14 | 15 | Use the scanner to find all switchbots in the area: 16 | ```python 17 | from switchbotpy import Scanner 18 | 19 | scanner = Scanner() 20 | mac_addresses = scanner.scan() 21 | ``` 22 | 23 | Use the mac address to create a bot instance providing methods to control the switchbots: 24 | ```python 25 | from switchbotpy import Bot 26 | 27 | bot = Bot(id=bot_id, mac=mac, name=name) 28 | bot.encrypted(password) # optional (only required in case the bot has a password) 29 | 30 | bot.press() # press the switchbot 31 | settings = bot.get_settings() # get a dict with the current bot settings 32 | 33 | # all other options can be found in the example folder 34 | ``` 35 | 36 | 37 | ## Switchbot BLE API 38 | 39 | For people interested in building an application controlling their switchbots, I provide a list with the results of my reverse engineering. I do not guarantee correctness nor completeness but with the BLE commands as described below I managed to control switchbots with firmware 4.4 and 4.5. 40 | The official switchbot app was used to set the password of the bots. 41 | 42 | ### Actions 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
RequestNotification (Response)
NameHandleUnencryptedEncryptedRequiredHandleValue
press0x160x 57 010x 57 11 pw80x13stat2
turn on0x 57 01 010x 57 11 pw8 01
turn off0x 57 01 020x 57 11 pw8 02
81 | 82 | - pw8: crc32 checksum of the password in 4 bytes 83 | - stat2: 1 = action complete, 3 = bot busy, 11 = bot unreachable, 7 = bot encrypted, 8 = bot unencrypted, 9 = wrong password 84 | 85 | ### Settings 86 | #### GET Settings 87 | 88 | The bot settings are all retrieved by triggering one notification which consists of the concatenated settings. 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
RequestNotification (Response)
NameHandleUnencryptedEncryptedRequiredHandleValue
get settings0x160x 57 020x 57 12 pw8x0x13 0x stat2 bat2 fw2 64 00 00 00 00 nt2 ds1 inv1 sec2
batterybat2: 1st byte of value
firmwarefw2: 2nd byte of value (div by 10)
number of timersnt2: 8th byte of value
dual state modeds1: first 4 bits of 9th byte of value
inverse directioninv1: last 4 bits of 9th byte of value
hold secondssec2: 10th byte of value
140 | 141 | - pw8: crc32 checksum of the password in 4 bytes 142 | - stat2: 1 = action complete, 3 = bot busy, 11 = bot unreachable, 7 = bot encrypted, 8 = bot unencrypted, 9 = wrong password 143 | 144 | #### SET Settings 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 |
RequestNotification (Response)
NameHandleUnencryptedEncryptedRequiredHandleValue
hold time0x160x 57 0f 08 sec20x 57 1f pw8 08 sec20x13stat2
mode0x 57 03 64 ds1inv10x 57 13 64 pw8 ds1inv1
177 | 178 | - pw8: crc32 checksum of the password in 4 bytes 179 | - sec2: seconds as one byte unsigned int 180 | - ds1: if dual state mode: 1 else 0 181 | - inv1: if inverse mode: 1 else 0 182 | - stat2: 1 = action complete, 3 = bot busy, 11 = bot unreachable, 7 = bot encrypted, 8 = bot unencrypted, 9 = wrong password 183 | 184 | ### Timers 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 |
RequestNotification (Response)
NameHandleUnencryptedEncryptedRequiredHandleValue
get timer0x160x 57 08 tid130x 57 18 pw8 tid13x0x13
set timer0x 57 09 tid13 timer200x 57 19 pw8 tid13 timer20stat2
sync timer0x 57 09 01 t160x 57 19 pw8 01 t16stat2
224 | 225 | - pw8: crc32 checksum of the password in 4 bytes 226 | - tid1: timer id between 0 and 4 227 | - timer20: nt2 00 rep2 hh2 mm2 rep11md1 rep21act1 its2 ihh2 imm2 228 | - nt2: number of timers as one byte (e.g. 0x03 if there are 3 timers set) 229 | - rep2: repeating pattern as one byte. Is 0x00 if timer is disabled. Is 0x80==b10000000 if there is no repetition. Otherwise, the last seven bits of the byte indicate the weekday on which the timer should be repeated (e.g. b01100000 means that the timer counts for Sunday and Saturday). 230 | - hh2: timer hour between 0 and 23 231 | - mm2: timer minute between 0 and 59 232 | - rep11: in case the timer is disabled (rep2=0), the first 4 bits of the repeating byte are stored here 233 | - md1: timer mode (standard=0, interval=1) as a byte, 234 | - rep21: in case the timer is disabled (rep2=0), the last 4 bits of the repeating byte are stored here 235 | - act1: timer action (press=0, turn_on=1, turn_off=2) as a byte 236 | - its2: interval timer sum 237 | - ihh2: interval timer hour 238 | - imm2: interval timer minutes 239 | - stat2: 1 = action complete, 3 = bot busy, 11 = bot unreachable, 7 = bot encrypted, 8 = bot unencrypted, 9 = wrong password 240 | 241 | 242 | ## Authors 243 | 244 | * **Nicolas Küchler** - *Initial work* - [nicolas-kuechler](https://github.com/nicolas-kuechler) 245 | -------------------------------------------------------------------------------- /switchbotpy/switchbot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scan and control Switchbots via BLE: 3 | - control actions (press, switch on, switch off) 4 | - control settings (set hold time, set mode, 5 | get battery, get firmware, get hold seconds, get mode, get number of timers) 6 | - control timers (get and set timers + sync timestamps) 7 | 8 | All operations support using a predefined password (configured via the official Switchbot App) 9 | """ 10 | 11 | import logging 12 | import re 13 | import time 14 | import zlib 15 | from binascii import hexlify 16 | from typing import Any, Dict, List, Tuple 17 | from uuid import UUID 18 | 19 | import pygatt 20 | 21 | from switchbotpy.switchbot_timer import BaseTimer, delete_timer_cmd, parse_timer_cmd 22 | from switchbotpy.switchbot_util import (ActionStatus, SwitchbotError, handle_notification, 23 | notification_queue) 24 | 25 | 26 | logging.basicConfig() 27 | LOG = logging.getLogger('switchbot') 28 | 29 | class Scanner(object): 30 | """ Switchbot Scanner class to scan for available switchbots (might require root privileges)""" 31 | 32 | def __init__(self): 33 | self.adapter = pygatt.GATTToolBackend() 34 | 35 | def scan(self, known_dict=None) -> List[str]: 36 | """Scan for available switchbots""" 37 | LOG.info("scanning for bots") 38 | try: 39 | self.adapter.start() 40 | devices = self.adapter.scan() 41 | finally: 42 | self.adapter.stop() 43 | 44 | switchbots = [] 45 | 46 | for device in devices: 47 | if known_dict is not None and device['address'] is not None: 48 | # mac of device is known 49 | # -> don't need to check characteristics to know if device is a switchbot 50 | if device['address'] in known_dict: 51 | switchbots.append(device['address']) 52 | elif self._is_switchbot(mac=device['address']): 53 | # mac of device is unknown 54 | # -> check characteristics to know if device is a switchbot 55 | switchbots.append(device['address']) 56 | 57 | return switchbots 58 | 59 | def _is_switchbot(self, mac: str) -> bool: 60 | try: 61 | self.adapter.start() 62 | device = self.adapter.connect(mac, address_type=pygatt.BLEAddressType.random) 63 | characteristics = self.adapter.discover_characteristics(device) 64 | device.disconnect() 65 | 66 | uuid1 = UUID("{cba20002-224d-11e6-9fb8-0002a5d5c51b}") 67 | uuid2 = UUID("{cba20003-224d-11e6-9fb8-0002a5d5c51b}") 68 | 69 | is_switchbot = uuid1 in characteristics.keys() and uuid2 in characteristics.keys() 70 | except pygatt.exceptions.NotConnectedError: 71 | # e.g. if device uses different addressing 72 | is_switchbot = False 73 | finally: 74 | self.adapter.stop() 75 | 76 | return is_switchbot 77 | 78 | 79 | class Bot(object): 80 | """Switchbot class to control the bot.""" 81 | 82 | def __init__(self, bot_id: int, mac: str, name: str): 83 | 84 | if not re.match(r"[0-9A-F]{2}(?:[-:][0-9A-F]{2}){5}$", mac): 85 | raise ValueError("Illegal Mac Address: ", mac) 86 | 87 | self.bot_id = bot_id 88 | self.mac = mac 89 | self.name = name 90 | 91 | self.adapter = pygatt.GATTToolBackend() 92 | self.device = None 93 | self.password = None 94 | self.notification_activated = False 95 | 96 | LOG.info("create bot: id=%d mac=%s name=%s", self.bot_id, self.mac, self.name) 97 | 98 | def press(self): 99 | """Press the Switchbot in the standard mode (non dual state mode): 100 | 1. Extend arm 101 | 2. Hold for configured number of seconds [see set_hold_time()] 102 | 3. Retract arm 103 | """ 104 | LOG.info("press bot") 105 | try: 106 | self.adapter.start() 107 | self._connect() 108 | self._activate_notifications() 109 | 110 | if self.password: 111 | cmd = b'\x57\x11' + self.password 112 | else: 113 | cmd = b'\x57\x01' 114 | 115 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 116 | self._handle_switchbot_status_msg(value=value) 117 | 118 | finally: 119 | self.adapter.stop() 120 | 121 | 122 | def switch(self, switch_on: bool): 123 | """Switch the state of the Switchbot in the dual state mode: 124 | (Extend arm or Retract arm) 125 | """ 126 | 127 | LOG.info("switch bot on=%s", str(switch_on)) 128 | try: 129 | self.adapter.start() 130 | self._connect() 131 | self._activate_notifications() 132 | 133 | if self.password: 134 | cmd = b'\x57\x11' + self.password 135 | else: 136 | cmd = b'\x57\x01' 137 | 138 | if switch_on: 139 | cmd += b'\x01' 140 | else: # off 141 | cmd += b'\x02' 142 | 143 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 144 | self._handle_switchbot_status_msg(value=value) 145 | 146 | finally: 147 | self.adapter.stop() 148 | 149 | 150 | def set_hold_time(self, sec: int): 151 | """Set the hold time for the Switchbot in the standard mode (up to one minute)""" 152 | 153 | LOG.info("set hold time: %d sec", sec) 154 | if sec < 0 or sec > 60: 155 | raise ValueError("hold time must be between [0, 60] seconds") 156 | 157 | try: 158 | self.adapter.start() 159 | self._connect() 160 | self._activate_notifications() 161 | 162 | if self.password: 163 | cmd = b'\x57\x1f' + self.password 164 | else: 165 | cmd = b'\x57\x0f' 166 | 167 | cmd += b'\x08' + sec.to_bytes(1, byteorder='big') 168 | 169 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 170 | self._handle_switchbot_status_msg(value=value) 171 | 172 | finally: 173 | self.adapter.stop() 174 | 175 | def get_timer(self, idx: int) -> Tuple[BaseTimer, int]: 176 | """Get all the configured timers of the Switchbot.""" 177 | 178 | LOG.info("get timer: %d", idx) 179 | try: 180 | self.adapter.start() 181 | self._connect() 182 | self._activate_notifications() 183 | 184 | if self.password: 185 | cmd = b'\x57\x18' + self.password 186 | else: 187 | cmd = b'\x57\x08' 188 | 189 | timer_id = (idx * 16 + 3).to_bytes(1, byteorder='big') 190 | cmd += timer_id 191 | 192 | # trigger and wait for notification 193 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 194 | self._handle_switchbot_status_msg(value=value) 195 | 196 | # parse result 197 | timer, num_timer = parse_timer_cmd(value) 198 | 199 | finally: 200 | self.adapter.stop() 201 | 202 | return timer, num_timer 203 | 204 | def set_timer(self, timer: BaseTimer, idx: int, num_timer: int): 205 | """Configure Switchbot timer.""" 206 | 207 | LOG.info("set timer: %d", idx) 208 | if idx < 0 or idx > 4 or num_timer <= idx or num_timer < 1 or num_timer > 5: 209 | raise ValueError("Illegal Timer Idx or Number of Timers") 210 | try: 211 | self.adapter.start() 212 | self._connect() 213 | self._activate_notifications() 214 | 215 | if self.password: 216 | cmd = b'\x57\x19' + self.password 217 | else: 218 | cmd = b'\x57\x09' 219 | 220 | cmd += timer.to_cmd(idx=idx, num_timer=num_timer) 221 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 222 | self._handle_switchbot_status_msg(value=value) 223 | 224 | finally: 225 | self.adapter.stop() 226 | 227 | 228 | def set_timers(self, timers: List[BaseTimer]): 229 | """Configure multiple Switchbot timers.""" 230 | 231 | LOG.info("set timers") 232 | try: 233 | self.adapter.start() 234 | self._connect() 235 | self._activate_notifications() 236 | 237 | if self.password: 238 | cmd_base = b'\x57\x19' + self.password 239 | else: 240 | cmd_base = b'\x57\x09' 241 | 242 | num_timer = len(timers) 243 | for i, timer in enumerate(timers): 244 | cmd = cmd_base 245 | cmd += timer.to_cmd(idx=i, num_timer=num_timer) 246 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 247 | self._handle_switchbot_status_msg(value=value) 248 | 249 | for i in range(num_timer, 5): 250 | cmd = cmd_base 251 | cmd += delete_timer_cmd(idx=i, num_timer=num_timer) 252 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 253 | self._handle_switchbot_status_msg(value=value) 254 | 255 | finally: 256 | self.adapter.stop() 257 | 258 | 259 | def set_current_timestamp(self): 260 | """Sync the timestamps for the timers.""" 261 | 262 | LOG.info("setting current timestamp") 263 | try: 264 | self.adapter.start() 265 | self._connect() 266 | self._activate_notifications() 267 | 268 | if self.password: 269 | cmd_base = b'\x57\x19' + self.password 270 | else: 271 | cmd_base = b'\x57\x09' 272 | 273 | time_sec_utc = time.time() 274 | time_local = time.localtime(time_sec_utc) 275 | offset = time_local.tm_gmtoff 276 | timestamp = int(time_sec_utc + offset) 277 | 278 | cmd = cmd_base + b'\x01' 279 | cmd += timestamp.to_bytes(8, byteorder='big') 280 | 281 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 282 | self._handle_switchbot_status_msg(value=value) 283 | 284 | finally: 285 | self.adapter.stop() 286 | 287 | 288 | def set_mode(self, dual_state: bool, inverse: bool): 289 | """Change the switchbot mode: 290 | Standard Mode: press() -> Extend / Hold / Retract arm 291 | Dual State Mode: 1. Extend arm with switch(on=True) 2. Retract arm with switch(on=False) 292 | Inverse: The Arm is extended by default and retracts on press() 293 | """ 294 | LOG.info("setting mode: dual_state=%s inverse=%s", str(dual_state), str(inverse)) 295 | LOG.info(" resetting all timers") 296 | 297 | # delete all timers 298 | # -> because if dual_state changes, then also action of timer needs to change 299 | self.set_timers(timers=[]) 300 | 301 | try: 302 | self.adapter.start() 303 | self._connect() 304 | self._activate_notifications() 305 | 306 | if self.password: 307 | cmd_base = b'\x57\x13' + self.password 308 | else: 309 | cmd_base = b'\x57\x03' 310 | 311 | cmd = cmd_base + b'\x64' 312 | 313 | config = 0 314 | if dual_state: 315 | config += 16 316 | if inverse: 317 | config += 1 318 | 319 | cmd += config.to_bytes(1, byteorder='big') 320 | 321 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 322 | self._handle_switchbot_status_msg(value=value) 323 | 324 | finally: 325 | self.adapter.stop() 326 | 327 | def get_settings(self) -> Dict[str, Any]: 328 | """ 329 | Get the Switchbot settings (battery, firmware, number of timers, 330 | mode (standard / dual state), inverse mode, hold seconds)""" 331 | 332 | LOG.info("get settings") 333 | try: 334 | self.adapter.start() 335 | self._connect() 336 | self._activate_notifications() 337 | 338 | if self.password: 339 | cmd = b'\x57\x12' + self.password 340 | else: 341 | cmd = b'\x57\x02' 342 | 343 | # trigger and wait for notification 344 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 345 | self._handle_switchbot_status_msg(value=value) 346 | 347 | # parse result 348 | settings = {} 349 | 350 | settings["battery"] = value[1] 351 | settings["firmware"] = value[2] / 10.0 352 | 353 | settings["n_timers"] = value[8] 354 | settings["dual_state_mode"] = bool(value[9] & 16) 355 | settings["inverse_direction"] = bool(value[9] & 1) 356 | settings["hold_seconds"] = value[10] 357 | 358 | finally: 359 | self.adapter.stop() 360 | 361 | return settings 362 | 363 | def get_timers(self, n_timers: int = 5) -> List[BaseTimer]: 364 | """Get the configured Switchbot timers""" 365 | 366 | LOG.info("get timers") 367 | try: 368 | self.adapter.start() 369 | self._connect() 370 | self._activate_notifications() 371 | 372 | if self.password: 373 | base_cmd = b'\x57\x18' + self.password 374 | else: 375 | base_cmd = b'\x57\x08' 376 | 377 | timers = [] 378 | 379 | for i in range(0, n_timers): 380 | timer_id = (i * 16 + 3).to_bytes(1, byteorder='big') 381 | cmd = base_cmd + timer_id 382 | 383 | # trigger and wait for notification 384 | value = self._write_cmd_and_wait_for_notification(handle=0x16, cmd=cmd) 385 | self._handle_switchbot_status_msg(value=value) 386 | 387 | # parse result 388 | timer, _ = parse_timer_cmd(value) 389 | 390 | if timer is None: 391 | # timer not set => all later also not set 392 | break 393 | 394 | # add to timers 395 | timers.append(timer) 396 | 397 | finally: 398 | self.adapter.stop() 399 | 400 | return timers 401 | 402 | def encrypted(self, password: str): 403 | """The Switchbot is configured with this password.""" 404 | 405 | LOG.info("use encrypted communication") 406 | data = password.encode() 407 | crc = zlib.crc32(data) 408 | self.password = crc.to_bytes(4, 'big') 409 | 410 | def _connect(self): 411 | try: 412 | self.device = self.adapter.connect(self.mac, address_type=pygatt.BLEAddressType.random) 413 | except pygatt.BLEError: 414 | LOG.exception("pygatt: failed to connect to ble device") 415 | raise SwitchbotError(message="communication with ble device failed") 416 | 417 | def _activate_notifications(self): 418 | uuid = "cba20003-224d-11e6-9fb8-0002a5d5c51b" 419 | try: 420 | self.device.subscribe(uuid, callback=handle_notification) 421 | self.notification_activated = True 422 | except pygatt.BLEError: 423 | LOG.exception("pygatt: failed to activate notifications") 424 | raise SwitchbotError(message="communication with ble device failed") 425 | 426 | def _write_cmd_and_wait_for_notification(self, handle, cmd, notification_timeout_sec=5): 427 | """ 428 | utility method to write a command to the handle and wait for a notification, 429 | (requires that notifications are activated) 430 | """ 431 | if not self.notification_activated: 432 | raise ValueError("notifications must be activated") 433 | LOG.debug("handle: %s cmd: %s", str(hex(handle)), str(hexlify(cmd))) 434 | 435 | try: 436 | # trigger the notification 437 | self.device.char_write_handle(handle=handle, value=cmd) 438 | 439 | # wait for notification to return 440 | _, value = notification_queue.get(timeout=notification_timeout_sec) 441 | 442 | except pygatt.BLEError: 443 | LOG.exception("pygatt: failed to write cmd and wait for notification") 444 | raise SwitchbotError(message="communication with ble device failed") 445 | 446 | 447 | LOG.debug("handle: %s cmd: %s notification: %s", 448 | str(hex(handle)), str(hexlify(cmd)), str(hexlify(value))) 449 | return value 450 | 451 | def _handle_switchbot_status_msg(self, value: bytearray): 452 | """ 453 | checks the status code of the value and raises an exception if the action did not complete 454 | """ 455 | 456 | status = value[0] 457 | action_status = ActionStatus(status) 458 | 459 | if action_status is not ActionStatus.complete: 460 | raise SwitchbotError(message=action_status.msg(), switchbot_action_status=action_status) 461 | --------------------------------------------------------------------------------