├── requirements.txt ├── setup.py ├── pyproject.toml ├── switchbot ├── scene.py ├── client.py ├── __init__.py ├── remotes.py └── devices.py ├── setup.cfg ├── LICENSE ├── .github └── workflows │ └── wheels.yml ├── README.md └── .gitignore /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pyhumps -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 120 7 | 8 | [tool.isort] 9 | profile = "black" 10 | line_length = 120 -------------------------------------------------------------------------------- /switchbot/scene.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from switchbot.client import SwitchBotClient 4 | 5 | 6 | class Scene: 7 | def __init__(self, client: SwitchBotClient, id: str, **extra): 8 | self.client = client 9 | self.id: str = id 10 | self.name: str = extra.get("scene_name") 11 | 12 | def execute(self): 13 | self.client.post(f"scenes/{self.id}/execute") 14 | 15 | def __repr__(self): 16 | name = "Scene" if self.name is None else self.name 17 | name = name.replace(" ", "") 18 | return f"{name}(id={self.id})" 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = python-switchbot 3 | version = attr: switchbot.__version__ 4 | description = A Python library to control SwitchBot devices connected to SwitchBot Hub 5 | author = Jonghwan Hyeon 6 | author_email = jonghwanhyeon93@gmail.com 7 | url = https://github.com/jonghwanhyeon/python-switchbot 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | keywords = 11 | switchbot 12 | license = MIT 13 | classifiers = 14 | Development Status :: 4 - Beta 15 | License :: OSI Approved :: MIT License 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3 :: Only 18 | Topic :: Home Automation 19 | 20 | [options] 21 | install_requires = 22 | requests 23 | pyhumps 24 | python_requires = >=3.7 25 | packages = find: -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jonghwan Hyeon 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 | -------------------------------------------------------------------------------- /.github/workflows/wheels.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build_wheels: 10 | name: Build wheels 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Build wheels 16 | run: pipx run build --wheel 17 | 18 | - uses: actions/upload-artifact@v3 19 | with: 20 | path: dist/*.whl 21 | 22 | build_sdist: 23 | name: Build source distribution 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | 28 | - name: Build sdist 29 | run: pipx run build --sdist 30 | 31 | - uses: actions/upload-artifact@v3 32 | with: 33 | path: dist/*.tar.gz 34 | 35 | upload_pypi: 36 | needs: [build_wheels, build_sdist] 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/download-artifact@v3 40 | with: 41 | # unpacks default artifact into dist/ 42 | # if `name: artifact` is omitted, the action will create extra parent dir 43 | name: artifact 44 | path: dist 45 | 46 | - uses: pypa/gh-action-pypi-publish@v1.5.0 47 | with: 48 | user: __token__ 49 | password: ${{ secrets.pypi_password }} 50 | 51 | upload_release: 52 | needs: [build_wheels, build_sdist] 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/download-artifact@v3 56 | with: 57 | # unpacks default artifact into dist/ 58 | # if `name: artifact` is omitted, the action will create extra parent dir 59 | name: artifact 60 | path: dist 61 | 62 | - name: Release 63 | uses: softprops/action-gh-release@v1 64 | with: 65 | files: dist/* -------------------------------------------------------------------------------- /switchbot/client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | import time 5 | from typing import Any 6 | 7 | import humps 8 | import requests 9 | 10 | switchbot_host = "https://api.switch-bot.com/v1.1" 11 | 12 | 13 | class SwitchBotClient: 14 | def __init__(self, token: str, secret: str, nonce: str): 15 | self.session = requests.Session() 16 | 17 | timestamp = int(round(time.time() * 1000)) 18 | signature = f"{token}{timestamp}{nonce}" 19 | signature = base64.b64encode( 20 | hmac.new( 21 | secret.encode(), 22 | msg=signature.encode(), 23 | digestmod=hashlib.sha256, 24 | ).digest() 25 | ) 26 | 27 | self.session.headers["Authorization"] = token 28 | self.session.headers["t"] = str(timestamp) 29 | self.session.headers["sign"] = signature 30 | self.session.headers["nonce"] = nonce 31 | 32 | def request(self, method: str, path: str, **kwargs) -> Any: 33 | url = f"{switchbot_host}/{path}" 34 | response = self.session.request(method, url, **kwargs) 35 | 36 | if response.status_code != 200: 37 | raise RuntimeError(f"SwitchBot API server returns status {response.status_code}") 38 | 39 | response_in_json = humps.decamelize(response.json()) 40 | if response_in_json["status_code"] != 100: 41 | raise RuntimeError(f'An error occurred: {response_in_json["message"]}') 42 | 43 | return response_in_json 44 | 45 | def get(self, path: str, **kwargs) -> Any: 46 | return self.request("GET", path, **kwargs) 47 | 48 | def post(self, path: str, **kwargs) -> Any: 49 | return self.request("POST", path, **kwargs) 50 | 51 | def put(self, path: str, **kwargs) -> Any: 52 | return self.request("PUT", path, **kwargs) 53 | 54 | def delete(self, path: str, **kwargs) -> Any: 55 | return self.request("DELETE", path, **kwargs) 56 | -------------------------------------------------------------------------------- /switchbot/__init__.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import List 3 | 4 | from switchbot.client import SwitchBotClient 5 | from switchbot.devices import Device 6 | from switchbot.remotes import Remote 7 | from switchbot.scene import Scene 8 | 9 | __version__ = "2.3.1" 10 | 11 | 12 | class SwitchBot: 13 | def __init__(self, token: str, secret: str): 14 | self.client = SwitchBotClient(token, secret, nonce=str(uuid.uuid4())) 15 | 16 | def devices(self) -> List[Device]: 17 | response = self.client.get("devices") 18 | return [ 19 | Device.create(client=self.client, id=device["device_id"], **device) 20 | for device in response["body"]["device_list"] 21 | ] 22 | 23 | def device(self, id: str) -> Device: 24 | # Currently, SwitchBot API does not support to retrieve device_name, 25 | # enable_cloud_service and hub_device_id without getting all device list 26 | # Therefore, for backward compatibility reason, 27 | # we query all devices first, then return the matching device 28 | for device in self.devices(): 29 | if device.id == id: 30 | return device 31 | raise ValueError(f"Unknown device {id}") 32 | 33 | def remotes(self) -> List[Remote]: 34 | response = self.client.get("devices") 35 | return [ 36 | Remote.create(client=self.client, id=remote["device_id"], **remote) 37 | for remote in response["body"]["infrared_remote_list"] 38 | ] 39 | 40 | def remote(self, id: str) -> Remote: 41 | for remote in self.remotes(): 42 | if remote.id == id: 43 | return remote 44 | raise ValueError(f"Unknown remote {id}") 45 | 46 | def scenes(self) -> List[Scene]: 47 | response = self.client.get("scenes") 48 | return [Scene(client=self.client, id=scene["scene_id"], **scene) for scene in response["body"]] 49 | 50 | def scene(self, id: str) -> Scene: 51 | for scene in self.scenes(): 52 | if scene.id == id: 53 | return scene 54 | raise ValueError(f"Unknown scene {id}") 55 | -------------------------------------------------------------------------------- /switchbot/remotes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar, Dict, Optional, Type 4 | 5 | import humps 6 | 7 | from switchbot.client import SwitchBotClient 8 | 9 | 10 | class Remote: 11 | remote_type_for: ClassVar[Optional[str]] = None 12 | specialized_cls: ClassVar[Dict[str, Type[Remote]]] = {} 13 | 14 | def __init__(self, client: SwitchBotClient, id: str, **extra): 15 | self.client = client 16 | 17 | self.id: str = id 18 | self.name: str = extra.get("device_name") 19 | self.type: str = extra.get("remote_type") 20 | self.hub_id: str = extra.get("hub_device_id") 21 | 22 | def __init_subclass__(cls): 23 | if cls.remote_type_for is not None: 24 | cls.specialized_cls[cls.remote_type_for] = cls 25 | 26 | @classmethod 27 | def create(cls, client: SwitchBotClient, id: str, **extra): 28 | remote_type = extra.get("remote_type") 29 | if remote_type == "Others": 30 | return OtherRemote(client, id=id, **extra) 31 | else: 32 | remote_cls = cls.specialized_cls.get(remote_type, SupportedRemote) 33 | return remote_cls(client, id=id, **extra) 34 | 35 | def command( 36 | self, 37 | action: str, 38 | parameter: Optional[str] = None, 39 | customize: Optional[bool] = False, 40 | ): 41 | parameter = "default" if parameter is None else parameter 42 | command_type = "customize" if customize else "command" 43 | payload = humps.camelize( 44 | { 45 | "command_type": command_type, 46 | "command": action if customize else humps.camelize(action), 47 | "parameter": parameter, 48 | } 49 | ) 50 | 51 | self.client.post(f"devices/{self.id}/commands", json=payload) 52 | 53 | def __repr__(self): 54 | name = "Remote" if self.type is None else self.type 55 | name = name.replace(" ", "") 56 | return f"{name}(id={self.id})" 57 | 58 | 59 | class SupportedRemote(Remote): 60 | def turn(self, state: str): 61 | state = state.lower() 62 | assert state in ("on", "off") 63 | self.command(f"turn_{state}") 64 | 65 | 66 | class OtherRemote(Remote): 67 | remote_type_for = "Others" 68 | 69 | def command(self, action: str, parameter: Optional[str] = None): 70 | super().command(action, parameter, True) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-switchbot 2 | A Python library to control SwitchBot devices connected to SwitchBot Hub 3 | 4 | ## Requirements 5 | - Python 3.7+ 6 | - [A SwitchBot Token](https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started) 7 | 8 | ## Installation 9 | ```python 10 | pip install python-switchbot 11 | ``` 12 | 13 | ## Usage 14 | 15 | 16 | ### Devices 17 | ```python 18 | from switchbot import SwitchBot 19 | 20 | # To get the token and secret, please refer to https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started 21 | your_switch_bot_token = '98a6732b2ac256d40ffab7db31a82f518969f4d1a64eadff581d45e902327b7c577aa6ead517bda589c19b4ca0b2599b' 22 | your_switch_bot_secret = '222cdc22f049d111c5d0071c131b8b77' 23 | switchbot = SwitchBot(token=your_switch_bot_token, secret=your_switch_bot_secret) 24 | # To list all devices 25 | devices = switchbot.devices() 26 | for device in devices: 27 | print(device) 28 | # Bot(id=CD0A18B1C291) 29 | # Lock(id=CD0A1221C291) 30 | # HubMini(id=4CAF08629A21) 31 | # Bot(id=5F0B798AEF91) 32 | 33 | # If you already know a device id: 34 | device = switchbot.device(id='5F0B798AEF91') 35 | # Device(id=5F0B798AEF91) 36 | 37 | # To query a status of a device 38 | print(device.status()) 39 | # {'power': 'off'} 40 | 41 | # To command actions, 42 | device.command('turn_on') 43 | device.command('turn_off') 44 | device.command('press') 45 | device.command('set_position', parameter='0,ff,80') 46 | 47 | # For some device types like Bot: 48 | bot = devices[0] 49 | bot.turn('on') 50 | bot.turn('off') 51 | bot.toggle() 52 | bot.press() 53 | 54 | # For some device types like Lock: 55 | lock = devices[1] 56 | lock.lock() 57 | lock.unlock() 58 | lock.toggle() 59 | ``` 60 | 61 | ### Remotes 62 | ```python 63 | # To list all infra red remotes 64 | remotes = switchbot.remotes() 65 | for remote in remotes: 66 | print(remote) 67 | 68 | # If you already know a remote id: 69 | remote = switchbot.remote(id='') 70 | 71 | # Supported devices such as fans, air purifiers: 72 | remote.turn('on') 73 | remote.turn('off') 74 | 75 | # To send supported commands, 76 | remote.command('swing') 77 | remote.command('low_speed') 78 | 79 | # To send custom commands, 80 | remote.command('MyCustomCommand', customize=True) 81 | ``` 82 | 83 | ### Scenes 84 | ```python 85 | # To list all infra red remotes 86 | scenes = switchbot.scenes() 87 | for scene in scenes: 88 | print(scene) 89 | 90 | # If you already know a remote id: 91 | scene = switchbot.scene(id='') 92 | 93 | # To execute scene 94 | scene.execute() 95 | ``` 96 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # vs code ide cache 132 | .vscode -------------------------------------------------------------------------------- /switchbot/devices.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, ClassVar, Dict, List, Optional, Type 4 | 5 | import humps 6 | 7 | from switchbot.client import SwitchBotClient 8 | 9 | 10 | class Device: 11 | device_type_for: ClassVar[Optional[str]] = None 12 | specialized_cls: ClassVar[Dict[str, Type[Device]]] = {} 13 | 14 | def __init__(self, client: SwitchBotClient, id: str, **extra): 15 | self.client = client 16 | 17 | self.id: str = id 18 | self.name: str = extra.get("device_name") 19 | self.type: str = extra.get("device_type") 20 | self.cloud_enabled: bool = extra.get("enable_cloud_service") 21 | self.hub_id: str = extra.get("hub_device_id") 22 | 23 | def __init_subclass__(cls): 24 | if cls.device_type_for is not None: 25 | cls.specialized_cls[cls.device_type_for] = cls 26 | 27 | @classmethod 28 | def create(cls, client: SwitchBotClient, id: str, **extra): 29 | device_type = extra.get("device_type") 30 | device_cls = cls.specialized_cls.get(device_type, Device) 31 | return device_cls(client, id=id, **extra) 32 | 33 | def status(self) -> Dict[str, Any]: 34 | response = self.client.get(f"devices/{self.id}/status") 35 | return {key: humps.decamelize(value) for key, value in response["body"].items()} 36 | 37 | def command(self, action: str, parameter: Optional[str] = None): 38 | parameter = "default" if parameter is None else parameter 39 | payload = humps.camelize( 40 | { 41 | "command_type": "command", 42 | "command": humps.camelize(action), 43 | "parameter": parameter, 44 | } 45 | ) 46 | 47 | self.client.post(f"devices/{self.id}/commands", json=payload) 48 | 49 | def __repr__(self): 50 | name = "Device" if self.type is None else self.type 51 | name = name.replace(" ", "") 52 | return f"{name}(id={self.id})" 53 | 54 | 55 | class Bot(Device): 56 | device_type_for = "Bot" 57 | 58 | def turn(self, state: str): 59 | state = state.lower() 60 | assert state in ("on", "off") 61 | self.command(f"turn_{state}") 62 | 63 | def press(self): 64 | self.command("press") 65 | 66 | def toggle(self): 67 | state = self.status()["power"] 68 | self.turn("on" if state == "off" else "off") 69 | 70 | 71 | class Curtain(Device): 72 | device_type_for = "Curtain" 73 | 74 | def __init__(self, client: SwitchBotClient, id: str, **extra): 75 | super().__init__(client, id, **extra) 76 | 77 | self.curtain_ids: List[str] = extra.get("curtain_devices_ids") 78 | self.calibrated: bool = extra.get("calibrate") 79 | self.grouped: bool = extra.get("group") 80 | self.master: bool = extra.get("master") 81 | self.open_direction: str = extra.get("open_direction") 82 | 83 | 84 | class Lock(Device): 85 | device_type_for = "Smart Lock" 86 | 87 | def lock(self): 88 | self.command("lock") 89 | 90 | def unlock(self): 91 | self.command("unlock") 92 | 93 | def toggle(self): 94 | state = self.status()["lock_state"] 95 | assert state in ("unlocked", "locked") 96 | 97 | if state == "unlocked": 98 | self.lock() 99 | else: 100 | self.unlock() 101 | --------------------------------------------------------------------------------