├── 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 | Request |
48 | Notification (Response) |
49 |
50 |
51 | | Name |
52 | Handle |
53 | Unencrypted |
54 | Encrypted |
55 | Required |
56 | Handle |
57 | Value |
58 |
59 |
60 | | press |
61 | 0x16 |
62 | 0x 57 01 |
63 | 0x 57 11 pw8 |
64 | |
65 | 0x13 |
66 | stat2 |
67 |
68 |
69 | | turn on |
70 | 0x 57 01 01 |
71 | 0x 57 11 pw8 01 |
72 | |
73 |
74 |
75 | | turn off |
76 | 0x 57 01 02 |
77 | 0x 57 11 pw8 02 |
78 | |
79 |
80 |
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 | Request |
94 | Notification (Response) |
95 |
96 |
97 | | Name |
98 | Handle |
99 | Unencrypted |
100 | Encrypted |
101 | Required |
102 | Handle |
103 | Value |
104 |
105 |
106 | | get settings |
107 | 0x16 |
108 | 0x 57 02 |
109 | 0x 57 12 pw8 |
110 | x |
111 | 0x13 |
112 | 0x stat2 bat2 fw2 64 00 00 00 00 nt2 ds1 inv1 sec2 |
113 |
114 |
115 | | battery |
116 | bat2: 1st byte of value |
117 |
118 |
119 | | firmware |
120 | fw2: 2nd byte of value (div by 10) |
121 |
122 |
123 | | number of timers |
124 | nt2: 8th byte of value |
125 |
126 |
127 | | dual state mode |
128 | ds1: first 4 bits of 9th byte of value |
129 |
130 |
131 | | inverse direction |
132 | inv1: last 4 bits of 9th byte of value |
133 |
134 |
135 | | hold seconds |
136 | sec2: 10th byte of value |
137 |
138 |
139 |
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 | Request |
150 | Notification (Response) |
151 |
152 |
153 | | Name |
154 | Handle |
155 | Unencrypted |
156 | Encrypted |
157 | Required |
158 | Handle |
159 | Value |
160 |
161 |
162 | | hold time |
163 | 0x16 |
164 | 0x 57 0f 08 sec2 |
165 | 0x 57 1f pw8 08 sec2 |
166 | |
167 | 0x13 |
168 | stat2 |
169 |
170 |
171 | | mode |
172 | 0x 57 03 64 ds1inv1 |
173 | 0x 57 13 64 pw8 ds1inv1 |
174 | |
175 |
176 |
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 | Request |
189 | Notification (Response) |
190 |
191 |
192 | | Name |
193 | Handle |
194 | Unencrypted |
195 | Encrypted |
196 | Required |
197 | Handle |
198 | Value |
199 |
200 |
201 | | get timer |
202 | 0x16 |
203 | 0x 57 08 tid13 |
204 | 0x 57 18 pw8 tid13 |
205 | x |
206 | 0x13 |
207 | |
208 |
209 |
210 | | set timer |
211 | 0x 57 09 tid13 timer20 |
212 | 0x 57 19 pw8 tid13 timer20 |
213 | |
214 | stat2 |
215 |
216 |
217 | | sync timer |
218 | 0x 57 09 01 t16 |
219 | 0x 57 19 pw8 01 t16 |
220 | |
221 | stat2 |
222 |
223 |
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 |
--------------------------------------------------------------------------------