├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── python-publish.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt ├── scripts └── get_encryption_key.py ├── setup.py ├── switchbot ├── __init__.py ├── adv_parser.py ├── adv_parsers │ ├── __init__.py │ ├── air_purifier.py │ ├── blind_tilt.py │ ├── bot.py │ ├── bulb.py │ ├── ceiling_light.py │ ├── contact.py │ ├── curtain.py │ ├── fan.py │ ├── hub2.py │ ├── hub3.py │ ├── hubmini_matter.py │ ├── humidifier.py │ ├── keypad.py │ ├── leak.py │ ├── light_strip.py │ ├── lock.py │ ├── meter.py │ ├── motion.py │ ├── plug.py │ ├── relay_switch.py │ ├── remote.py │ ├── roller_shade.py │ └── vacuum.py ├── api_config.py ├── const │ ├── __init__.py │ ├── air_purifier.py │ ├── evaporative_humidifier.py │ ├── fan.py │ ├── hub2.py │ ├── hub3.py │ └── lock.py ├── devices │ ├── __init__.py │ ├── air_purifier.py │ ├── base_cover.py │ ├── base_light.py │ ├── blind_tilt.py │ ├── bot.py │ ├── bulb.py │ ├── ceiling_light.py │ ├── contact.py │ ├── curtain.py │ ├── device.py │ ├── evaporative_humidifier.py │ ├── fan.py │ ├── humidifier.py │ ├── keypad.py │ ├── light_strip.py │ ├── lock.py │ ├── meter.py │ ├── motion.py │ ├── plug.py │ ├── relay_switch.py │ ├── roller_shade.py │ └── vacuum.py ├── discovery.py ├── enum.py ├── helpers.py └── models.py └── tests ├── __init__.py ├── test_adv_parser.py ├── test_air_purifier.py ├── test_base_cover.py ├── test_blind_tilt.py ├── test_curtain.py ├── test_evaporative_humidifier.py ├── test_fan.py ├── test_helpers.py ├── test_hub2.py ├── test_hub3.py ├── test_lock.py ├── test_relay_switch.py ├── test_roller_shade.py └── test_vacuum.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: "chore(ci): " 14 | groups: 15 | github-actions: 16 | patterns: 17 | - "*" 18 | - package-ecosystem: "pip" # See documentation for possible values 19 | directory: "/" # Location of package manifests 20 | schedule: 21 | interval: "weekly" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | coverage: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | python-version: ["3.11", "3.12", "3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: "pip" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements_dev.txt . 27 | - uses: pre-commit/action@v3.0.1 28 | - name: Tests 29 | run: pytest --cov=switchbot --cov-report=term-missing --cov-report=xml tests 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v5.4.3 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} # required 34 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | name: Upload Python Package 4 | 5 | on: 6 | release: 7 | types: [created] 8 | 9 | jobs: 10 | build: 11 | name: Build distribution 📦 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install pypa/build 21 | run: >- 22 | python3 -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: python3 -m build 28 | - name: Store the distribution packages 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: python-package-distributions 32 | path: dist/ 33 | 34 | deploy: 35 | permissions: 36 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 37 | runs-on: ubuntu-latest 38 | needs: 39 | - build 40 | name: >- 41 | Publish Python 🐍 distribution 📦 to PyPI 42 | environment: 43 | name: pypi 44 | url: https://pypi.org/p/pySwitchbot 45 | 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish package distributions to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /.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 | switchbot/.vs/slnx.sqlite 106 | switchbot/.vs/switchbot/v16/.suo 107 | switchbot/.vs/VSWorkspaceState.json 108 | switchbot/.vs/ProjectSettings.json 109 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "CHANGELOG.md" 4 | default_stages: [pre-commit] 5 | 6 | ci: 7 | autofix_commit_msg: "chore(pre-commit.ci): auto fixes" 8 | autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" 9 | 10 | repos: 11 | - repo: https://github.com/commitizen-tools/commitizen 12 | rev: v4.8.2 13 | hooks: 14 | - id: commitizen 15 | stages: [commit-msg] 16 | - repo: https://github.com/pre-commit/pre-commit-hooks 17 | rev: v5.0.0 18 | hooks: 19 | - id: debug-statements 20 | - id: check-builtin-literals 21 | - id: check-case-conflict 22 | - id: check-docstring-first 23 | - id: check-json 24 | - id: check-toml 25 | - id: check-xml 26 | - id: check-yaml 27 | - id: detect-private-key 28 | - id: end-of-file-fixer 29 | - id: trailing-whitespace 30 | - id: debug-statements 31 | - repo: https://github.com/pre-commit/mirrors-prettier 32 | rev: v4.0.0-alpha.8 33 | hooks: 34 | - id: prettier 35 | - repo: https://github.com/asottile/pyupgrade 36 | rev: v3.20.0 37 | hooks: 38 | - id: pyupgrade 39 | args: [--py311-plus] 40 | - repo: https://github.com/astral-sh/ruff-pre-commit 41 | rev: v0.11.12 42 | hooks: 43 | - id: ruff 44 | args: [--fix] 45 | - id: ruff-format 46 | - repo: https://github.com/cdce8p/python-typing-update 47 | rev: v0.7.2 48 | hooks: 49 | - id: python-typing-update 50 | stages: [manual] 51 | args: 52 | - --py311-plus 53 | - --force 54 | - --keep-updates 55 | files: ^(switchbot)/.+\.py$ 56 | - repo: https://github.com/codespell-project/codespell 57 | rev: v2.4.1 58 | hooks: 59 | - id: codespell 60 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | matrix: 3 | fast_finish: true 4 | include: 5 | - python: "3.5" 6 | env: TOXENV=lint 7 | cache: 8 | directories: 9 | - "$HOME/.cache/pip" 10 | install: 11 | - pip install flake8 pylint bluepy 12 | language: python 13 | script: 14 | - flake8 switchbot --max-line-length=120 15 | - pylint switchbot --max-line-length=120 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Daniel Høyer Iversen 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pySwitchbot [![codecov](https://codecov.io/gh/sblibs/pySwitchbot/graph/badge.svg?token=TI027U5ISQ)](https://codecov.io/gh/sblibs/pySwitchbot) 2 | 3 | Library to control Switchbot IoT devices https://www.switch-bot.com/bot 4 | 5 | ## Obtaining encryption key for Switchbot Locks 6 | 7 | Using the script `scripts/get_encryption_key.py` you can manually obtain locks encryption key. 8 | 9 | Usage: 10 | 11 | ```shell 12 | $ python3 get_encryption_key.py MAC USERNAME 13 | Key ID: XX 14 | Encryption key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 15 | ``` 16 | 17 | Where `MAC` is MAC address of the lock and `USERNAME` is your SwitchBot account username, after that script will ask for your password. 18 | If authentication succeeds then script should output your key id and encryption key. 19 | 20 | ## Examples: 21 | 22 | #### WoLock (Lock-Pro) 23 | 24 | Unlock: 25 | 26 | ```python 27 | import asyncio 28 | from switchbot.discovery import GetSwitchbotDevices 29 | from switchbot.devices import lock 30 | from switchbot.const import SwitchbotModel 31 | 32 | BLE_MAC="XX:XX:XX:XX:XX:XX" # The MAC of your lock 33 | KEY_ID="XX" # The key-ID of your encryption-key for your lock 34 | ENC_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # The encryption-key with key-ID "XX" 35 | LOCK_MODEL=SwitchbotModel.LOCK_PRO # Your lock model (here we use the Lock-Pro) 36 | 37 | 38 | async def main(): 39 | wolock = await GetSwitchbotDevices().get_locks() 40 | await lock.SwitchbotLock( 41 | wolock[BLE_MAC].device, KEY_ID, ENCRYPTION_KEY, model=LOCK_MODEL 42 | ).unlock() 43 | 44 | 45 | asyncio.run(main()) 46 | ``` 47 | 48 | Lock: 49 | 50 | ```python 51 | import asyncio 52 | from switchbot.discovery import GetSwitchbotDevices 53 | from switchbot.devices import lock 54 | from switchbot.const import SwitchbotModel 55 | 56 | BLE_MAC="XX:XX:XX:XX:XX:XX" # The MAC of your lock 57 | KEY_ID="XX" # The key-ID of your encryption-key for your lock 58 | ENC_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # The encryption-key with key-ID "XX" 59 | LOCK_MODEL=SwitchbotModel.LOCK_PRO # Your lock model (here we use the Lock-Pro) 60 | 61 | 62 | async def main(): 63 | wolock = await GetSwitchbotDevices().get_locks() 64 | await lock.SwitchbotLock( 65 | wolock[BLE_MAC].device, KEY_ID, ENCRYPTION_KEY, model=LOCK_MODEL 66 | ).lock() 67 | 68 | 69 | asyncio.run(main()) 70 | ``` 71 | 72 | #### WoCurtain (Curtain 3) 73 | 74 | ```python 75 | import asyncio 76 | from pprint import pprint 77 | from switchbot import GetSwitchbotDevices 78 | from switchbot.devices import curtain 79 | 80 | 81 | async def main(): 82 | # get the BLE advertisement data of all switchbot devices in the vicinity 83 | advertisement_data = await GetSwitchbotDevices().discover() 84 | 85 | for i in advertisement_data.values(): 86 | pprint(i) 87 | print() # print newline so that devices' data is separated visually 88 | 89 | # find your device's BLE address by inspecting the above printed debug logs, example below 90 | ble_address = "9915077C-C6FD-5FF6-27D3-45087898790B" 91 | # get the BLE device (via its address) and construct a curtain device 92 | ble_device = advertisement_data[ble_address].device 93 | curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=False) 94 | 95 | pprint(await curtain_device.get_device_data()) 96 | pprint(await curtain_device.get_basic_info()) 97 | await curtain_device.set_position(100) 98 | 99 | 100 | if __name__ == "__main__": 101 | asyncio.run(main()) 102 | ``` 103 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py311" 3 | line-length = 88 4 | 5 | [tool.ruff.lint] 6 | ignore = [ 7 | "S101", # use of assert 8 | "D203", # 1 blank line required before class docstring 9 | "D212", # Multi-line docstring summary should start at the first line 10 | "D100", # Missing docstring in public module 11 | "D101", # Missing docstring in public module 12 | "D102", # Missing docstring in public method 13 | "D103", # Missing docstring in public module 14 | "D104", # Missing docstring in public package 15 | "D105", # Missing docstring in magic method 16 | "D107", # Missing docstring in `__init__` 17 | "D400", # First line should end with a period 18 | "D401", # First line of docstring should be in imperative mood 19 | "D205", # 1 blank line required between summary line and description 20 | "D415", # First line should end with a period, question mark, or exclamation point 21 | "D417", # Missing argument descriptions in the docstring 22 | "E501", # Line too long 23 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` 24 | "B008", # Do not perform function call 25 | "S110", # `try`-`except`-`pass` detected, consider logging the exception 26 | "D106", # Missing docstring in public nested class 27 | "UP007", # typer needs Optional syntax 28 | "UP038", # Use `X | Y` in `isinstance` is slower 29 | "S603", # check for execution of untrusted input 30 | "S105", # possible hard coded creds 31 | "TID252", # not for this lib 32 | "TRY003", # nice but too many to fix, 33 | "G201", # too noisy 34 | "PLR2004", # too many to fix 35 | ] 36 | select = [ 37 | "ASYNC", # async rules 38 | "B", # flake8-bugbear 39 | "D", # flake8-docstrings 40 | "C4", # flake8-comprehensions 41 | "S", # flake8-bandit 42 | "F", # pyflake 43 | "E", # pycodestyle 44 | "W", # pycodestyle 45 | "UP", # pyupgrade 46 | "I", # isort 47 | "RUF", # ruff specific 48 | "FLY", # flynt 49 | "G", # flake8-logging-format , 50 | "PERF", # Perflint 51 | "PGH", # pygrep-hooks 52 | "PIE", # flake8-pie 53 | "PL", # pylint 54 | "PT", # flake8-pytest-style 55 | "PTH", # flake8-pathlib 56 | "PYI", # flake8-pyi 57 | "RET", # flake8-return 58 | "RSE", # flake8-raise , 59 | "SIM", # flake8-simplify 60 | "SLF", # flake8-self 61 | "SLOT", # flake8-slots 62 | "T100", # Trace found: {name} used 63 | "T20", # flake8-print 64 | "TID", # Tidy imports 65 | "TRY", # tryceratops 66 | ] 67 | 68 | [tool.ruff.lint.per-file-ignores] 69 | "tests/**/*" = [ 70 | "D100", 71 | "D101", 72 | "D102", 73 | "D103", 74 | "D104", 75 | "S101", 76 | "SLF001", 77 | "PLR2004", 78 | ] 79 | "setup.py" = ["D100"] 80 | "conftest.py" = ["D100"] 81 | "docs/conf.py" = ["D100"] 82 | "scripts/**/*" = [ 83 | "T201" 84 | ] 85 | 86 | [tool.ruff.lint.isort] 87 | known-first-party = ["pySwitchbot", "tests"] 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.9.5 2 | bleak>=0.17.0 3 | bleak-retry-connector>=2.9.0 4 | cryptography>=38.0.3 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest-asyncio 2 | pytest-cov 3 | aiohttp>=3.9.5 4 | bleak>=0.17.0 5 | bleak-retry-connector>=3.4.0 6 | cryptography>=38.0.3 7 | -------------------------------------------------------------------------------- /scripts/get_encryption_key.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import getpass 3 | import sys 4 | 5 | from switchbot import SwitchbotLock 6 | 7 | 8 | def main(): 9 | if len(sys.argv) < 3: 10 | print(f"Usage: {sys.argv[0]} []") 11 | sys.exit(1) 12 | 13 | password = getpass.getpass() if len(sys.argv) == 3 else sys.argv[3] 14 | 15 | try: 16 | result = SwitchbotLock.retrieve_encryption_key( 17 | sys.argv[1], sys.argv[2], password 18 | ) 19 | except RuntimeError as e: 20 | print(e) 21 | sys.exit(1) 22 | 23 | print("Key ID: " + result["key_id"]) 24 | print("Encryption key: " + result["encryption_key"]) 25 | 26 | 27 | if __name__ == "__main__": 28 | main() 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import setup 4 | 5 | this_directory = Path(__file__).parent 6 | long_description = (this_directory / "README.md").read_text() 7 | 8 | setup( 9 | name="PySwitchbot", 10 | packages=[ 11 | "switchbot", 12 | "switchbot.devices", 13 | "switchbot.const", 14 | "switchbot.adv_parsers", 15 | ], 16 | install_requires=[ 17 | "aiohttp>=3.9.5", 18 | "bleak>=0.19.0", 19 | "bleak-retry-connector>=3.4.0", 20 | "cryptography>=39.0.0", 21 | "pyOpenSSL>=23.0.0", 22 | ], 23 | version="0.65.0", 24 | description="A library to communicate with Switchbot", 25 | long_description=long_description, 26 | long_description_content_type="text/markdown", 27 | author="Daniel Hjelseth Hoyer", 28 | url="https://github.com/sblibs/pySwitchbot/", 29 | license="MIT", 30 | python_requires=">=3.11", 31 | classifiers=[ 32 | "Development Status :: 3 - Alpha", 33 | "Environment :: Other Environment", 34 | "Intended Audience :: Developers", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Topic :: Home Automation", 38 | "Topic :: Software Development :: Libraries :: Python Modules", 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /switchbot/__init__.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | from bleak_retry_connector import ( 6 | close_stale_connections, 7 | close_stale_connections_by_address, 8 | get_device, 9 | ) 10 | 11 | from .adv_parser import SwitchbotSupportedType, parse_advertisement_data 12 | from .const import ( 13 | AirPurifierMode, 14 | FanMode, 15 | HumidifierAction, 16 | HumidifierMode, 17 | HumidifierWaterLevel, 18 | LockStatus, 19 | SwitchbotAccountConnectionError, 20 | SwitchbotApiError, 21 | SwitchbotAuthenticationError, 22 | SwitchbotModel, 23 | ) 24 | from .devices.air_purifier import SwitchbotAirPurifier 25 | from .devices.base_light import SwitchbotBaseLight 26 | from .devices.blind_tilt import SwitchbotBlindTilt 27 | from .devices.bot import Switchbot 28 | from .devices.bulb import SwitchbotBulb 29 | from .devices.ceiling_light import SwitchbotCeilingLight 30 | from .devices.curtain import SwitchbotCurtain 31 | from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice 32 | from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier 33 | from .devices.fan import SwitchbotFan 34 | from .devices.humidifier import SwitchbotHumidifier 35 | from .devices.light_strip import SwitchbotLightStrip 36 | from .devices.lock import SwitchbotLock 37 | from .devices.plug import SwitchbotPlugMini 38 | from .devices.relay_switch import SwitchbotRelaySwitch, SwitchbotRelaySwitch2PM 39 | from .devices.roller_shade import SwitchbotRollerShade 40 | from .devices.vacuum import SwitchbotVacuum 41 | from .discovery import GetSwitchbotDevices 42 | from .models import SwitchBotAdvertisement 43 | 44 | __all__ = [ 45 | "AirPurifierMode", 46 | "ColorMode", 47 | "FanMode", 48 | "GetSwitchbotDevices", 49 | "HumidifierAction", 50 | "HumidifierMode", 51 | "HumidifierWaterLevel", 52 | "LockStatus", 53 | "SwitchBotAdvertisement", 54 | "Switchbot", 55 | "Switchbot", 56 | "SwitchbotAccountConnectionError", 57 | "SwitchbotAirPurifier", 58 | "SwitchbotApiError", 59 | "SwitchbotAuthenticationError", 60 | "SwitchbotBaseLight", 61 | "SwitchbotBlindTilt", 62 | "SwitchbotBulb", 63 | "SwitchbotCeilingLight", 64 | "SwitchbotCurtain", 65 | "SwitchbotDevice", 66 | "SwitchbotEncryptedDevice", 67 | "SwitchbotEvaporativeHumidifier", 68 | "SwitchbotFan", 69 | "SwitchbotHumidifier", 70 | "SwitchbotLightStrip", 71 | "SwitchbotLock", 72 | "SwitchbotModel", 73 | "SwitchbotModel", 74 | "SwitchbotPlugMini", 75 | "SwitchbotPlugMini", 76 | "SwitchbotRelaySwitch", 77 | "SwitchbotRelaySwitch2PM", 78 | "SwitchbotRollerShade", 79 | "SwitchbotSupportedType", 80 | "SwitchbotSupportedType", 81 | "SwitchbotVacuum", 82 | "close_stale_connections", 83 | "close_stale_connections_by_address", 84 | "get_device", 85 | "parse_advertisement_data", 86 | ] 87 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/__init__.py: -------------------------------------------------------------------------------- 1 | """Switchbot Advertisement Parser Library.""" 2 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/air_purifier.py: -------------------------------------------------------------------------------- 1 | """Air Purifier adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import struct 6 | 7 | from ..const.air_purifier import AirPurifierMode, AirQualityLevel 8 | 9 | 10 | def process_air_purifier( 11 | data: bytes | None, mfr_data: bytes | None 12 | ) -> dict[str, bool | int]: 13 | """Process air purifier services data.""" 14 | if mfr_data is None: 15 | return {} 16 | device_data = mfr_data[6:] 17 | 18 | _seq_num = device_data[0] 19 | _isOn = bool(device_data[1] & 0b10000000) 20 | _mode = device_data[1] & 0b00000111 21 | _is_aqi_valid = bool(device_data[2] & 0b00000100) 22 | _child_lock = bool(device_data[2] & 0b00000010) 23 | _speed = device_data[3] & 0b01111111 24 | _aqi_level = (device_data[4] & 0b00000110) >> 1 25 | _aqi_level = AirQualityLevel(_aqi_level).name.lower() 26 | _work_time = struct.unpack(">H", device_data[5:7])[0] 27 | _err_code = device_data[7] 28 | 29 | return { 30 | "isOn": _isOn, 31 | "mode": get_air_purifier_mode(_mode, _speed), 32 | "isAqiValid": _is_aqi_valid, 33 | "child_lock": _child_lock, 34 | "speed": _speed, 35 | "aqi_level": _aqi_level, 36 | "filter element working time": _work_time, 37 | "err_code": _err_code, 38 | "sequence_number": _seq_num, 39 | } 40 | 41 | 42 | def get_air_purifier_mode(mode: int, speed: int) -> str | None: 43 | if mode == 1: 44 | if 0 <= speed <= 33: 45 | return "level_1" 46 | if 34 <= speed <= 66: 47 | return "level_2" 48 | return "level_3" 49 | if 1 < mode <= 4: 50 | mode += 2 51 | return AirPurifierMode(mode).name.lower() 52 | return None 53 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/blind_tilt.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_woblindtilt( 7 | data: bytes | None, mfr_data: bytes | None, reverse: bool = False 8 | ) -> dict[str, bool | int]: 9 | """Process woBlindTilt services data.""" 10 | if mfr_data is None: 11 | return {} 12 | 13 | device_data = mfr_data[6:] 14 | 15 | _tilt = max(min(device_data[2] & 0b01111111, 100), 0) 16 | _in_motion = bool(device_data[2] & 0b10000000) 17 | _light_level = (device_data[3] >> 4) & 0b00001111 18 | _calibrated = bool(device_data[1] & 0b00000001) 19 | 20 | return { 21 | "calibration": _calibrated, 22 | "battery": data[2] & 0b01111111 if data else None, 23 | "inMotion": _in_motion, 24 | "tilt": (100 - _tilt) if reverse else _tilt, 25 | "lightLevel": _light_level, 26 | "sequence_number": device_data[0], 27 | } 28 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/bot.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_wohand(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: 7 | """Process woHand/Bot services data.""" 8 | if data is None and mfr_data is None: 9 | return {} 10 | 11 | if data is None: 12 | return { 13 | "switchMode": None, 14 | "isOn": None, 15 | "battery": None, 16 | } 17 | 18 | _switch_mode = bool(data[1] & 0b10000000) 19 | 20 | return { 21 | "switchMode": _switch_mode, 22 | "isOn": not bool(data[1] & 0b01000000) if _switch_mode else False, 23 | "battery": data[2] & 0b01111111, 24 | } 25 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/bulb.py: -------------------------------------------------------------------------------- 1 | """Bulb parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_color_bulb( 7 | data: bytes | None, mfr_data: bytes | None 8 | ) -> dict[str, bool | int]: 9 | """Process WoBulb services data.""" 10 | if mfr_data is None: 11 | return {} 12 | return { 13 | "sequence_number": mfr_data[6], 14 | "isOn": bool(mfr_data[7] & 0b10000000), 15 | "brightness": mfr_data[7] & 0b01111111, 16 | "delay": bool(mfr_data[8] & 0b10000000), 17 | "preset": bool(mfr_data[8] & 0b00001000), 18 | "color_mode": mfr_data[8] & 0b00000111, 19 | "speed": mfr_data[9] & 0b01111111, 20 | "loop_index": mfr_data[10] & 0b11111110, 21 | } 22 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/ceiling_light.py: -------------------------------------------------------------------------------- 1 | """Ceiling Light adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | # Off d94b2d012b3c4864106124 10 | # on d94b2d012b3c4a641061a4 11 | # Off d94b2d012b3c4b64106124 12 | # on d94b2d012b3c4d641061a4 13 | # 00112233445566778899AA 14 | 15 | 16 | def process_woceiling(data: bytes, mfr_data: bytes | None) -> dict[str, bool | int]: 17 | """Process WoCeiling services data.""" 18 | if mfr_data is None: 19 | return {} 20 | return { 21 | "sequence_number": mfr_data[6], 22 | "isOn": bool(mfr_data[10] & 0b10000000), 23 | "brightness": mfr_data[7] & 0b01111111, 24 | "cw": int(mfr_data[8:10].hex(), 16), 25 | "color_mode": 1, 26 | } 27 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/contact.py: -------------------------------------------------------------------------------- 1 | """Contact sensor parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_wocontact( 7 | data: bytes | None, mfr_data: bytes | None 8 | ) -> dict[str, bool | int]: 9 | """Process woContact Sensor services data.""" 10 | if data is None and mfr_data is None: 11 | return {} 12 | 13 | battery = data[2] & 0b01111111 if data else None 14 | tested = bool(data[1] & 0b10000000) if data else None 15 | 16 | if mfr_data and len(mfr_data) >= 13: 17 | motion_detected = bool(mfr_data[7] & 0b10000000) 18 | contact_open = bool(mfr_data[7] & 0b00010000) 19 | contact_timeout = bool(mfr_data[7] & 0b00100000) 20 | button_count = mfr_data[12] & 0b00001111 21 | is_light = bool(mfr_data[7] & 0b01000000) 22 | else: 23 | motion_detected = bool(data[1] & 0b01000000) 24 | contact_open = bool(data[3] & 0b00000010) 25 | contact_timeout = bool(data[3] & 0b00000100) 26 | button_count = data[8] & 0b00001111 27 | is_light = bool(data[3] & 0b00000001) 28 | 29 | return { 30 | "tested": tested, 31 | "motion_detected": motion_detected, 32 | "battery": battery, 33 | "contact_open": contact_open or contact_timeout, # timeout still means its open 34 | "contact_timeout": contact_timeout, 35 | "is_light": is_light, 36 | "button_count": button_count, 37 | } 38 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/curtain.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_wocurtain( 7 | data: bytes | None, mfr_data: bytes | None, reverse: bool = True 8 | ) -> dict[str, bool | int]: 9 | """Process woCurtain/Curtain services data.""" 10 | if mfr_data and len(mfr_data) >= 13: # Curtain 3 11 | device_data = mfr_data[8:11] 12 | battery_data = mfr_data[12] 13 | elif mfr_data and len(mfr_data) >= 11: 14 | device_data = mfr_data[8:11] 15 | battery_data = data[2] if data else None 16 | elif data: 17 | device_data = data[3:6] 18 | battery_data = data[2] 19 | else: 20 | return {} 21 | 22 | _position = max(min(device_data[0] & 0b01111111, 100), 0) 23 | _in_motion = bool(device_data[0] & 0b10000000) 24 | _light_level = (device_data[1] >> 4) & 0b00001111 25 | _device_chain = device_data[1] & 0b00000111 26 | 27 | return { 28 | "calibration": bool(data[1] & 0b01000000) if data else None, 29 | "battery": battery_data & 0b01111111 if battery_data is not None else None, 30 | "inMotion": _in_motion, 31 | "position": (100 - _position) if reverse else _position, 32 | "lightLevel": _light_level, 33 | "deviceChain": _device_chain, 34 | } 35 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/fan.py: -------------------------------------------------------------------------------- 1 | """Fan adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | from ..const.fan import FanMode 6 | 7 | 8 | def process_fan(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: 9 | """Process fan services data.""" 10 | if mfr_data is None: 11 | return {} 12 | 13 | device_data = mfr_data[6:] 14 | 15 | _seq_num = device_data[0] 16 | _isOn = bool(device_data[1] & 0b10000000) 17 | _mode = (device_data[1] & 0b01110000) >> 4 18 | _mode = FanMode(_mode).name.lower() if 1 <= _mode <= 4 else None 19 | _nightLight = (device_data[1] & 0b00001100) >> 2 20 | _oscillate_left_and_right = bool(device_data[1] & 0b00000010) 21 | _oscillate_up_and_down = bool(device_data[1] & 0b00000001) 22 | _battery = device_data[2] & 0b01111111 23 | _speed = device_data[3] & 0b01111111 24 | 25 | return { 26 | "sequence_number": _seq_num, 27 | "isOn": _isOn, 28 | "mode": _mode, 29 | "nightLight": _nightLight, 30 | "oscillating": _oscillate_left_and_right | _oscillate_up_and_down, 31 | "battery": _battery, 32 | "speed": _speed, 33 | } 34 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/hub2.py: -------------------------------------------------------------------------------- 1 | """Hub2 parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from ..const.hub2 import LIGHT_INTENSITY_MAP 8 | from ..helpers import celsius_to_fahrenheit 9 | 10 | 11 | def process_wohub2(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: 12 | """Process woHub2 sensor manufacturer data.""" 13 | temp_data = None 14 | 15 | if mfr_data: 16 | status = mfr_data[12] 17 | temp_data = mfr_data[13:16] 18 | 19 | if not temp_data: 20 | return {} 21 | 22 | _temp_sign = 1 if temp_data[1] & 0b10000000 else -1 23 | _temp_c = _temp_sign * ( 24 | (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10) 25 | ) 26 | _temp_f = celsius_to_fahrenheit(_temp_c) 27 | _temp_f = (_temp_f * 10) / 10 28 | humidity = temp_data[2] & 0b01111111 29 | light_level = status & 0b11111 30 | 31 | if _temp_c == 0 and humidity == 0: 32 | return {} 33 | 34 | return { 35 | # Data should be flat, but we keep the original structure for now 36 | "temp": {"c": _temp_c, "f": _temp_f}, 37 | "temperature": _temp_c, 38 | "fahrenheit": bool(temp_data[2] & 0b10000000), 39 | "humidity": humidity, 40 | "lightLevel": light_level, 41 | "illuminance": calculate_light_intensity(light_level), 42 | } 43 | 44 | 45 | def calculate_light_intensity(light_level: int) -> int: 46 | """ 47 | Convert Hub 2 light level (1-21) to actual light intensity value 48 | Args: 49 | light_level: Integer from 1-21 50 | Returns: 51 | Corresponding light intensity value or 0 if invalid input 52 | """ 53 | if not light_level: 54 | return 0 55 | return LIGHT_INTENSITY_MAP.get(max(0, min(light_level, 22)), 0) 56 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/hub3.py: -------------------------------------------------------------------------------- 1 | """Hub3 adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from ..const.hub3 import LIGHT_INTENSITY_MAP 8 | from ..helpers import celsius_to_fahrenheit 9 | 10 | 11 | def process_hub3(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: 12 | """Process hub3 sensor manufacturer data.""" 13 | if mfr_data is None: 14 | return {} 15 | device_data = mfr_data[6:] 16 | 17 | seq_num = device_data[0] 18 | network_state = (device_data[6] & 0b11000000) >> 6 19 | sensor_inserted = not bool(device_data[6] & 0b00100000) 20 | light_level = device_data[6] & 0b00001111 21 | illuminance = calculate_light_intensity(light_level) 22 | temperature_alarm = bool(device_data[7] & 0b11000000) 23 | humidity_alarm = bool(device_data[7] & 0b00110000) 24 | 25 | temp_data = device_data[7:10] 26 | _temp_sign = 1 if temp_data[1] & 0b10000000 else -1 27 | _temp_c = _temp_sign * ( 28 | (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10) 29 | ) 30 | _temp_f = round(celsius_to_fahrenheit(_temp_c), 1) 31 | humidity = temp_data[2] & 0b01111111 32 | motion_detected = bool(device_data[10] & 0b10000000) 33 | 34 | return { 35 | "sequence_number": seq_num, 36 | "network_state": network_state, 37 | "sensor_inserted": sensor_inserted, 38 | "lightLevel": light_level, 39 | "illuminance": illuminance, 40 | "temperature_alarm": temperature_alarm, 41 | "humidity_alarm": humidity_alarm, 42 | "temp": {"c": _temp_c, "f": _temp_f}, 43 | "temperature": _temp_c, 44 | "humidity": humidity, 45 | "motion_detected": motion_detected, 46 | } 47 | 48 | 49 | def calculate_light_intensity(light_level: int) -> int: 50 | """ 51 | Convert Hub 3 light level (1-10) to actual light intensity value 52 | Args: 53 | light_level: Integer from 1-10 54 | Returns: 55 | Corresponding light intensity value or 0 if invalid input 56 | """ 57 | return LIGHT_INTENSITY_MAP.get(max(0, min(light_level, 10)), 0) 58 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/hubmini_matter.py: -------------------------------------------------------------------------------- 1 | """Hubmini matter parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from ..helpers import celsius_to_fahrenheit 8 | 9 | 10 | def process_hubmini_matter( 11 | data: bytes | None, mfr_data: bytes | None 12 | ) -> dict[str, Any]: 13 | """Process Hubmini matter sensor manufacturer data.""" 14 | temp_data = None 15 | 16 | if mfr_data: 17 | temp_data = mfr_data[13:16] 18 | 19 | if not temp_data: 20 | return {} 21 | 22 | _temp_sign = 1 if temp_data[1] & 0b10000000 else -1 23 | _temp_c = _temp_sign * ( 24 | (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10) 25 | ) 26 | _temp_f = celsius_to_fahrenheit(_temp_c) 27 | _temp_f = (_temp_f * 10) / 10 28 | humidity = temp_data[2] & 0b01111111 29 | 30 | if _temp_c == 0 and humidity == 0: 31 | return {} 32 | 33 | return { 34 | "temp": {"c": _temp_c, "f": _temp_f}, 35 | "temperature": _temp_c, 36 | "fahrenheit": bool(temp_data[2] & 0b10000000), 37 | "humidity": humidity, 38 | } 39 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/humidifier.py: -------------------------------------------------------------------------------- 1 | """Humidifier adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from datetime import timedelta 7 | 8 | from ..const.evaporative_humidifier import ( 9 | HumidifierMode, 10 | HumidifierWaterLevel, 11 | ) 12 | from ..helpers import celsius_to_fahrenheit 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | # mfr_data: 943cc68d3d2e 17 | # data: 650000cd802b6300 18 | # data: 650000cd802b6300 19 | # data: 658000c9802b6300 20 | 21 | 22 | # Low: 658000c5222b6300 23 | # Med: 658000c5432b6300 24 | # High: 658000c5642b6300 25 | 26 | 27 | def calculate_temperature_and_humidity( 28 | data: bytes, is_meter_binded: bool = True 29 | ) -> tuple[float | None, float | None, int | None]: 30 | """Calculate temperature and humidity based on the given flag.""" 31 | if len(data) < 3 or not is_meter_binded: 32 | return None, None, None 33 | 34 | humidity = data[0] & 0b01111111 35 | if humidity > 100: 36 | return None, None, None 37 | 38 | _temp_sign = 1 if data[1] & 0b10000000 else -1 39 | _temp_c = _temp_sign * ((data[1] & 0b01111111) + ((data[2] >> 4) / 10)) 40 | _temp_f = celsius_to_fahrenheit(_temp_c) 41 | 42 | return _temp_c, _temp_f, humidity 43 | 44 | 45 | def process_wohumidifier( 46 | data: bytes | None, mfr_data: bytes | None 47 | ) -> dict[str, bool | int]: 48 | """Process WoHumi services data.""" 49 | if data is None: 50 | return { 51 | "isOn": None, 52 | "level": None, 53 | "switchMode": True, 54 | } 55 | 56 | return { 57 | "isOn": bool(data[1]), 58 | "level": data[4], 59 | "switchMode": True, 60 | } 61 | 62 | 63 | def process_evaporative_humidifier( 64 | data: bytes | None, mfr_data: bytes | None 65 | ) -> dict[str, bool | int]: 66 | """Process WoHumi services data.""" 67 | if mfr_data is None: 68 | return {} 69 | 70 | seq_number = mfr_data[6] 71 | is_on = bool(mfr_data[7] & 0b10000000) 72 | mode = HumidifierMode(mfr_data[7] & 0b00001111) 73 | over_humidify_protection = bool(mfr_data[8] & 0b10000000) 74 | child_lock = bool(mfr_data[8] & 0b00100000) 75 | tank_removed = bool(mfr_data[8] & 0b00000100) 76 | tilted_alert = bool(mfr_data[8] & 0b00000010) 77 | filter_missing = bool(mfr_data[8] & 0b00000001) 78 | is_meter_binded = bool(mfr_data[9] & 0b10000000) 79 | 80 | _temp_c, _temp_f, humidity = calculate_temperature_and_humidity( 81 | mfr_data[9:12], is_meter_binded 82 | ) 83 | 84 | water_level = HumidifierWaterLevel(mfr_data[11] & 0b00000011).name.lower() 85 | filter_run_time = timedelta( 86 | hours=int.from_bytes(mfr_data[12:14], byteorder="big") & 0xFFF 87 | ) 88 | target_humidity = mfr_data[16] & 0b01111111 89 | 90 | return { 91 | "seq_number": seq_number, 92 | "isOn": is_on, 93 | "mode": mode, 94 | "over_humidify_protection": over_humidify_protection, 95 | "child_lock": child_lock, 96 | "tank_removed": tank_removed, 97 | "tilted_alert": tilted_alert, 98 | "filter_missing": filter_missing, 99 | "is_meter_binded": is_meter_binded, 100 | "humidity": humidity, 101 | "temperature": _temp_c, 102 | "temp": {"c": _temp_c, "f": _temp_f}, 103 | "water_level": water_level, 104 | "filter_run_time": filter_run_time, 105 | "filter_alert": filter_run_time.days >= 10, 106 | "target_humidity": target_humidity, 107 | } 108 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/keypad.py: -------------------------------------------------------------------------------- 1 | """Keypad parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | def process_wokeypad( 11 | data: bytes | None, 12 | mfr_data: bytes | None, 13 | ) -> dict[str, bool | int | None]: 14 | """Process woKeypad services data.""" 15 | if data is None or mfr_data is None: 16 | return {"battery": None, "attempt_state": None} 17 | 18 | _LOGGER.debug("mfr_data: %s", mfr_data.hex()) 19 | if data: 20 | _LOGGER.debug("data: %s", data.hex()) 21 | 22 | return {"battery": data[2] & 0b01111111, "attempt_state": mfr_data[6]} 23 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/leak.py: -------------------------------------------------------------------------------- 1 | """Leak detector adv parser.""" 2 | 3 | 4 | def process_leak(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: 5 | """Process SwitchBot Water Leak Detector advertisement data.""" 6 | if data is None or len(data) < 3 or mfr_data is None or len(mfr_data) < 2: 7 | return {} 8 | 9 | water_leak_detected = None 10 | device_tampered = None 11 | battery_level = None 12 | low_battery = None 13 | 14 | # Byte 1: Event Flags 15 | event_flags = mfr_data[8] 16 | water_leak_detected = bool(event_flags & 0b00000001) # Bit 0 17 | device_tampered = bool(event_flags & 0b00000010) # Bit 1 18 | 19 | # Byte 2: Battery Info 20 | battery_info = mfr_data[7] 21 | battery_level = battery_info & 0b01111111 # Bits 0-6 22 | low_battery = bool(battery_info & 0b10000000) # Bit 7 23 | 24 | return { 25 | "leak": water_leak_detected, 26 | "tampered": device_tampered, 27 | "battery": battery_level, 28 | "low_battery": low_battery, 29 | } 30 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/light_strip.py: -------------------------------------------------------------------------------- 1 | """Light strip adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_wostrip( 7 | data: bytes | None, mfr_data: bytes | None 8 | ) -> dict[str, bool | int]: 9 | """Process WoStrip services data.""" 10 | if mfr_data is None: 11 | return {} 12 | return { 13 | "sequence_number": mfr_data[6], 14 | "isOn": bool(mfr_data[7] & 0b10000000), 15 | "brightness": mfr_data[7] & 0b01111111, 16 | "delay": bool(mfr_data[8] & 0b10000000), 17 | "preset": bool(mfr_data[8] & 0b00001000), 18 | "color_mode": mfr_data[8] & 0b00000111, 19 | "speed": mfr_data[9] & 0b01111111, 20 | "loop_index": mfr_data[10] & 0b11111110, 21 | } 22 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/lock.py: -------------------------------------------------------------------------------- 1 | """Lock parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from ..const.lock import LockStatus 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | def process_wolock(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: 13 | """Support for lock and lock lite process data.""" 14 | if mfr_data is None: 15 | return {} 16 | 17 | _LOGGER.debug("mfr_data: %s", mfr_data.hex()) 18 | if data: 19 | _LOGGER.debug("data: %s", data.hex()) 20 | 21 | return { 22 | "sequence_number": mfr_data[6], 23 | "battery": data[2] & 0b01111111 if data else None, 24 | "calibration": bool(mfr_data[7] & 0b10000000), 25 | "status": LockStatus((mfr_data[7] & 0b01110000) >> 4), 26 | "update_from_secondary_lock": bool(mfr_data[7] & 0b00001000), 27 | "door_open": bool(mfr_data[7] & 0b00000100), 28 | "double_lock_mode": bool(mfr_data[8] & 0b10000000), 29 | "unclosed_alarm": bool(mfr_data[8] & 0b00100000), 30 | "unlocked_alarm": bool(mfr_data[8] & 0b00010000), 31 | "auto_lock_paused": bool(mfr_data[8] & 0b00000010), 32 | "night_latch": bool(mfr_data[9] & 0b00000001) if len(mfr_data) > 9 else False, 33 | } 34 | 35 | 36 | def parse_common_data(mfr_data: bytes | None) -> dict[str, bool | int]: 37 | if mfr_data is None: 38 | return {} 39 | 40 | return { 41 | "sequence_number": mfr_data[6], 42 | "calibration": bool(mfr_data[7] & 0b10000000), 43 | "status": LockStatus((mfr_data[7] & 0b01111000) >> 4), 44 | "update_from_secondary_lock": bool(mfr_data[8] & 0b11000000), 45 | "door_open_from_secondary_lock": bool(mfr_data[8] & 0b00100000), 46 | "door_open": bool(mfr_data[8] & 0b00010000), 47 | "auto_lock_paused": bool(mfr_data[8] & 0b00001000), 48 | "battery": mfr_data[9] & 0b01111111, 49 | "double_lock_mode": bool(mfr_data[10] & 0b10000000), 50 | "is_secondary_lock": bool(mfr_data[10] & 0b01000000), 51 | "manual_unlock_linkage": bool(mfr_data[10] & 0b00100000), 52 | "unclosed_alarm": bool(mfr_data[11] & 0b10000000), 53 | "unlocked_alarm": bool(mfr_data[11] & 0b01000000), 54 | "night_latch": False, 55 | } 56 | 57 | 58 | def process_wolock_pro( 59 | data: bytes | None, mfr_data: bytes | None 60 | ) -> dict[str, bool | int]: 61 | """Support for lock pro process data.""" 62 | common_data = parse_common_data(mfr_data) 63 | if not common_data: 64 | return {} 65 | 66 | lock_pro_data = { 67 | "low_temperature_alarm": bool(mfr_data[11] & 0b00100000), 68 | "left_battery_compartment_alarm": mfr_data[11] & 0b000000100, 69 | "right_battery_compartment_alarm": mfr_data[11] & 0b000000010, 70 | } 71 | return common_data | lock_pro_data 72 | 73 | 74 | def process_lock2(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: 75 | """Support for lock2 process data.""" 76 | common_data = parse_common_data(mfr_data) 77 | if not common_data: 78 | return {} 79 | 80 | lock2_data = { 81 | "power_alarm": bool(mfr_data[11] & 0b00010000), 82 | "battery_status": mfr_data[11] & 0b00000111, 83 | } 84 | 85 | return common_data | lock2_data 86 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/meter.py: -------------------------------------------------------------------------------- 1 | """Meter parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import struct 6 | from typing import Any 7 | 8 | from ..helpers import celsius_to_fahrenheit 9 | 10 | CO2_UNPACK = struct.Struct(">H").unpack_from 11 | 12 | 13 | def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: 14 | """Process woSensorTH/Temp sensor services data.""" 15 | temp_data: bytes | None = None 16 | battery: bytes | None = None 17 | 18 | if mfr_data: 19 | temp_data = mfr_data[8:11] 20 | 21 | if data: 22 | if not temp_data: 23 | temp_data = data[3:6] 24 | battery = data[2] & 0b01111111 25 | 26 | if not temp_data: 27 | return {} 28 | 29 | _temp_sign = 1 if temp_data[1] & 0b10000000 else -1 30 | _temp_c = _temp_sign * ( 31 | (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10) 32 | ) 33 | _temp_f = celsius_to_fahrenheit(_temp_c) 34 | _temp_f = (_temp_f * 10) / 10 35 | humidity = temp_data[2] & 0b01111111 36 | 37 | if _temp_c == 0 and humidity == 0 and battery == 0: 38 | return {} 39 | 40 | return { 41 | # Data should be flat, but we keep the original structure for now 42 | "temp": {"c": _temp_c, "f": _temp_f}, 43 | "temperature": _temp_c, 44 | "fahrenheit": bool(temp_data[2] & 0b10000000), 45 | "humidity": humidity, 46 | "battery": battery, 47 | } 48 | 49 | 50 | def process_wosensorth_c(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: 51 | """Process woSensorTH/Temp sensor services data with CO2.""" 52 | _wosensorth_data = process_wosensorth(data, mfr_data) 53 | if _wosensorth_data and mfr_data and len(mfr_data) >= 15: 54 | co2_data = mfr_data[13:15] 55 | _wosensorth_data["co2"] = CO2_UNPACK(co2_data)[0] 56 | return _wosensorth_data 57 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/motion.py: -------------------------------------------------------------------------------- 1 | """Motion sensor parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_wopresence( 7 | data: bytes | None, mfr_data: bytes | None 8 | ) -> dict[str, bool | int]: 9 | """Process WoPresence Sensor services data.""" 10 | if data is None and mfr_data is None: 11 | return {} 12 | tested = None 13 | battery = None 14 | led = None 15 | iot = None 16 | sense_distance = None 17 | light_intensity = None 18 | is_light = None 19 | 20 | if data: 21 | tested = bool(data[1] & 0b10000000) 22 | motion_detected = bool(data[1] & 0b01000000) 23 | battery = data[2] & 0b01111111 24 | led = (data[5] & 0b00100000) >> 5 25 | iot = (data[5] & 0b00010000) >> 4 26 | sense_distance = (data[5] & 0b00001100) >> 2 27 | light_intensity = data[5] & 0b00000011 28 | is_light = bool(data[5] & 0b00000010) 29 | if mfr_data and len(mfr_data) >= 8: 30 | motion_detected = bool(mfr_data[7] & 0b01000000) 31 | is_light = bool(mfr_data[7] & 0b00100000) 32 | 33 | return { 34 | "tested": tested, 35 | "motion_detected": motion_detected, 36 | "battery": battery, 37 | "led": led, 38 | "iot": iot, 39 | "sense_distance": sense_distance, 40 | "light_intensity": light_intensity, 41 | "is_light": is_light, 42 | } 43 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/plug.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | from ..helpers import parse_power_data 6 | 7 | 8 | def process_woplugmini( 9 | data: bytes | None, mfr_data: bytes | None 10 | ) -> dict[str, bool | int]: 11 | """Process plug mini.""" 12 | if mfr_data is None: 13 | return {} 14 | return { 15 | "switchMode": True, 16 | "isOn": mfr_data[7] == 0x80, 17 | "wifi_rssi": -mfr_data[9], 18 | "power": parse_power_data(mfr_data, 10, 10.0, 0x7FFF), # W 19 | } 20 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/relay_switch.py: -------------------------------------------------------------------------------- 1 | """Relay Switch adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | 8 | def process_relay_switch_common_data( 9 | data: bytes | None, mfr_data: bytes | None 10 | ) -> dict[str, Any]: 11 | """Process relay switch 1 and 1PM common data.""" 12 | if mfr_data is None: 13 | return {} 14 | return { 15 | "switchMode": True, # for compatibility, useless 16 | "sequence_number": mfr_data[6], 17 | "isOn": bool(mfr_data[7] & 0b10000000), 18 | } 19 | 20 | 21 | def process_garage_door_opener( 22 | data: bytes | None, mfr_data: bytes | None 23 | ) -> dict[str, Any]: 24 | """Process garage door opener services data.""" 25 | if mfr_data is None: 26 | return {} 27 | common_data = process_relay_switch_common_data(data, mfr_data) 28 | common_data["door_open"] = not bool(mfr_data[7] & 0b00100000) 29 | return common_data 30 | 31 | 32 | def process_relay_switch_2pm( 33 | data: bytes | None, mfr_data: bytes | None 34 | ) -> dict[int, dict[str, Any]]: 35 | """Process Relay Switch 2PM services data.""" 36 | if mfr_data is None: 37 | return {} 38 | 39 | return { 40 | 1: { 41 | **process_relay_switch_common_data(data, mfr_data), 42 | }, 43 | 2: { 44 | "switchMode": True, # for compatibility, useless 45 | "sequence_number": mfr_data[6], 46 | "isOn": bool(mfr_data[7] & 0b01000000), 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/remote.py: -------------------------------------------------------------------------------- 1 | """Remote adv parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | def process_woremote( 11 | data: bytes | None, mfr_data: bytes | None 12 | ) -> dict[str, int | None]: 13 | """Process WoRemote adv data.""" 14 | if data is None: 15 | return { 16 | "battery": None, 17 | } 18 | 19 | _LOGGER.debug("data: %s", data.hex()) 20 | 21 | return { 22 | "battery": data[2] & 0b01111111, 23 | } 24 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/roller_shade.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | def process_worollershade( 7 | data: bytes | None, mfr_data: bytes | None, reverse: bool = True 8 | ) -> dict[str, bool | int]: 9 | """Process woRollerShade services data.""" 10 | if mfr_data is None: 11 | return {} 12 | 13 | device_data = mfr_data[6:] 14 | 15 | _position = max(min(device_data[2] & 0b01111111, 100), 0) 16 | _calibrated = bool(device_data[2] & 0b10000000) 17 | _in_motion = bool(device_data[1] & 0b00000110) 18 | _light_level = (device_data[3] >> 4) & 0b00001111 19 | _device_chain = device_data[3] & 0b00001111 20 | 21 | return { 22 | "calibration": _calibrated, 23 | "battery": data[2] & 0b01111111 if data else None, 24 | "inMotion": _in_motion, 25 | "position": (100 - _position) if reverse else _position, 26 | "lightLevel": _light_level, 27 | "deviceChain": _device_chain, 28 | "sequence_number": device_data[0], 29 | } 30 | -------------------------------------------------------------------------------- /switchbot/adv_parsers/vacuum.py: -------------------------------------------------------------------------------- 1 | """Vacuum parser.""" 2 | 3 | from __future__ import annotations 4 | 5 | import struct 6 | 7 | 8 | def process_vacuum( 9 | data: bytes | None, mfr_data: bytes | None 10 | ) -> dict[str, bool | int | str]: 11 | """Support for s10, k10+ pro combo, k20 process service data.""" 12 | if mfr_data is None: 13 | return {} 14 | 15 | _seq_num = mfr_data[6] 16 | _soc_version = get_device_fw_version(mfr_data[8:11]) 17 | # Steps at the end of the last network configuration 18 | _step = mfr_data[11] & 0b00001111 19 | _mqtt_connected = bool(mfr_data[11] & 0b00010000) 20 | _battery = mfr_data[12] 21 | _work_status = mfr_data[13] & 0b00111111 22 | 23 | return { 24 | "sequence_number": _seq_num, 25 | "soc_version": _soc_version, 26 | "step": _step, 27 | "mqtt_connected": _mqtt_connected, 28 | "battery": _battery, 29 | "work_status": _work_status, 30 | } 31 | 32 | 33 | def get_device_fw_version(version_bytes: bytes) -> str | None: 34 | version1 = version_bytes[0] & 0x0F 35 | version2 = version_bytes[0] >> 4 36 | version3 = struct.unpack("03d}" 38 | 39 | 40 | def process_vacuum_k( 41 | data: bytes | None, mfr_data: bytes | None 42 | ) -> dict[str, bool | int | str]: 43 | """Support for k10+, k10+ pro process service data.""" 44 | if mfr_data is None: 45 | return {} 46 | 47 | _seq_num = mfr_data[6] 48 | _dustbin_bound = bool(mfr_data[7] & 0b10000000) 49 | _dusbin_connected = bool(mfr_data[7] & 0b01000000) 50 | _network_connected = bool(mfr_data[7] & 0b00100000) 51 | _work_status = (mfr_data[7] & 0b00010000) >> 4 52 | _battery = mfr_data[8] & 0b01111111 53 | 54 | return { 55 | "sequence_number": _seq_num, 56 | "dustbin_bound": _dustbin_bound, 57 | "dusbin_connected": _dusbin_connected, 58 | "network_connected": _network_connected, 59 | "work_status": _work_status, 60 | "battery": _battery, 61 | } 62 | -------------------------------------------------------------------------------- /switchbot/api_config.py: -------------------------------------------------------------------------------- 1 | # Those values have been obtained from the following files in SwitchBot Android app 2 | # That's how you can verify them yourself 3 | # /assets/switchbot_config.json 4 | 5 | SWITCHBOT_APP_API_BASE_URL = "api.switchbot.net" 6 | SWITCHBOT_APP_CLIENT_ID = "5nnwmhmsa9xxskm14hd85lm9bm" 7 | -------------------------------------------------------------------------------- /switchbot/const/__init__.py: -------------------------------------------------------------------------------- 1 | """Switchbot Device Consts Library.""" 2 | 3 | from __future__ import annotations 4 | 5 | from ..enum import StrEnum 6 | from .air_purifier import AirPurifierMode 7 | from .evaporative_humidifier import ( 8 | HumidifierAction, 9 | HumidifierMode, 10 | HumidifierWaterLevel, 11 | ) 12 | from .fan import FanMode 13 | 14 | # Preserve old LockStatus export for backwards compatibility 15 | from .lock import LockStatus 16 | 17 | DEFAULT_RETRY_COUNT = 3 18 | DEFAULT_RETRY_TIMEOUT = 1 19 | DEFAULT_SCAN_TIMEOUT = 5 20 | 21 | 22 | class SwitchbotApiError(RuntimeError): 23 | """ 24 | Raised when API call fails. 25 | 26 | This exception inherits from RuntimeError to avoid breaking existing code 27 | but will be changed to Exception in a future release. 28 | """ 29 | 30 | 31 | class SwitchbotAuthenticationError(RuntimeError): 32 | """ 33 | Raised when authentication fails. 34 | 35 | This exception inherits from RuntimeError to avoid breaking existing code 36 | but will be changed to Exception in a future release. 37 | """ 38 | 39 | 40 | class SwitchbotAccountConnectionError(RuntimeError): 41 | """ 42 | Raised when connection to Switchbot account fails. 43 | 44 | This exception inherits from RuntimeError to avoid breaking existing code 45 | but will be changed to Exception in a future release. 46 | """ 47 | 48 | 49 | class SwitchbotModel(StrEnum): 50 | BOT = "WoHand" 51 | CURTAIN = "WoCurtain" 52 | HUMIDIFIER = "WoHumi" 53 | PLUG_MINI = "WoPlug" 54 | CONTACT_SENSOR = "WoContact" 55 | LIGHT_STRIP = "WoStrip" 56 | METER = "WoSensorTH" 57 | METER_PRO = "WoTHP" 58 | METER_PRO_C = "WoTHPc" 59 | IO_METER = "WoIOSensorTH" 60 | MOTION_SENSOR = "WoPresence" 61 | COLOR_BULB = "WoBulb" 62 | CEILING_LIGHT = "WoCeiling" 63 | LOCK = "WoLock" 64 | LOCK_PRO = "WoLockPro" 65 | BLIND_TILT = "WoBlindTilt" 66 | HUB2 = "WoHub2" 67 | LEAK = "Leak Detector" 68 | KEYPAD = "WoKeypad" 69 | RELAY_SWITCH_1PM = "Relay Switch 1PM" 70 | RELAY_SWITCH_1 = "Relay Switch 1" 71 | REMOTE = "WoRemote" 72 | EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier" 73 | ROLLER_SHADE = "Roller Shade" 74 | HUBMINI_MATTER = "HubMini Matter" 75 | CIRCULATOR_FAN = "Circulator Fan" 76 | K20_VACUUM = "K20 Vacuum" 77 | S10_VACUUM = "S10 Vacuum" 78 | K10_VACUUM = "K10+ Vacuum" 79 | K10_PRO_VACUUM = "K10+ Pro Vacuum" 80 | K10_PRO_COMBO_VACUUM = "K10+ Pro Combo Vacuum" 81 | AIR_PURIFIER = "Air Purifier" 82 | AIR_PURIFIER_TABLE = "Air Purifier Table" 83 | HUB3 = "Hub3" 84 | LOCK_ULTRA = "Lock Ultra" 85 | LOCK_LITE = "Lock Lite" 86 | GARAGE_DOOR_OPENER = "Garage Door Opener" 87 | RELAY_SWITCH_2PM = "Relay Switch 2PM" 88 | 89 | 90 | __all__ = [ 91 | "DEFAULT_RETRY_COUNT", 92 | "DEFAULT_RETRY_TIMEOUT", 93 | "DEFAULT_SCAN_TIMEOUT", 94 | "AirPurifierMode", 95 | "FanMode", 96 | "HumidifierAction", 97 | "HumidifierMode", 98 | "HumidifierWaterLevel", 99 | "LockStatus", 100 | "SwitchbotAccountConnectionError", 101 | "SwitchbotApiError", 102 | "SwitchbotAuthenticationError", 103 | "SwitchbotModel", 104 | ] 105 | -------------------------------------------------------------------------------- /switchbot/const/air_purifier.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class AirPurifierMode(Enum): 7 | LEVEL_1 = 1 8 | LEVEL_2 = 2 9 | LEVEL_3 = 3 10 | AUTO = 4 11 | SLEEP = 5 12 | PET = 6 13 | 14 | @classmethod 15 | def get_modes(cls) -> list[str]: 16 | return [mode.name.lower() for mode in cls] 17 | 18 | 19 | class AirQualityLevel(Enum): 20 | EXCELLENT = 0 21 | GOOD = 1 22 | MODERATE = 2 23 | UNHEALTHY = 3 24 | -------------------------------------------------------------------------------- /switchbot/const/evaporative_humidifier.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class HumidifierMode(Enum): 7 | HIGH = 1 8 | MEDIUM = 2 9 | LOW = 3 10 | QUIET = 4 11 | TARGET_HUMIDITY = 5 12 | SLEEP = 6 13 | AUTO = 7 14 | DRYING_FILTER = 8 15 | 16 | @classmethod 17 | def get_modes(cls) -> list[str]: 18 | return [mode.name.lower() for mode in cls] 19 | 20 | 21 | class HumidifierWaterLevel(Enum): 22 | EMPTY = 0 23 | LOW = 1 24 | MEDIUM = 2 25 | HIGH = 3 26 | 27 | @classmethod 28 | def get_levels(cls) -> list[str]: 29 | return [level.name.lower() for level in cls] 30 | 31 | 32 | class HumidifierAction(Enum): 33 | OFF = 0 34 | HUMIDIFYING = 1 35 | DRYING = 2 36 | 37 | 38 | OVER_HUMIDIFY_PROTECTION_MODES = { 39 | HumidifierMode.QUIET, 40 | HumidifierMode.LOW, 41 | HumidifierMode.MEDIUM, 42 | HumidifierMode.HIGH, 43 | } 44 | 45 | TARGET_HUMIDITY_MODES = { 46 | HumidifierMode.SLEEP, 47 | HumidifierMode.TARGET_HUMIDITY, 48 | } 49 | -------------------------------------------------------------------------------- /switchbot/const/fan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class FanMode(Enum): 7 | NORMAL = 1 8 | NATURAL = 2 9 | SLEEP = 3 10 | BABY = 4 11 | 12 | @classmethod 13 | def get_modes(cls) -> list[str]: 14 | return [mode.name.lower() for mode in cls] 15 | -------------------------------------------------------------------------------- /switchbot/const/hub2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mapping of light levels to lux measurement values for SwitchBot Hub 2. 3 | 4 | Source: After-sales consultation, line chart data provided by switchbot developers 5 | """ 6 | 7 | LIGHT_INTENSITY_MAP = { 8 | 1: 0, 9 | 2: 10, 10 | 3: 20, 11 | 4: 30, 12 | 5: 40, 13 | 6: 50, 14 | 7: 60, 15 | 8: 70, 16 | 9: 80, 17 | 10: 90, 18 | 11: 105, 19 | 12: 205, 20 | 13: 317, 21 | 14: 416, 22 | 15: 510, 23 | 16: 610, 24 | 17: 707, 25 | 18: 801, 26 | 19: 897, 27 | 20: 1023, 28 | 21: 1091, 29 | } 30 | -------------------------------------------------------------------------------- /switchbot/const/hub3.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mapping of light levels to lux measurement values for SwitchBot Hub 3. 3 | 4 | Source: After-sales consultation, line chart data provided by switchbot developers 5 | """ 6 | 7 | LIGHT_INTENSITY_MAP = { 8 | 1: 0, 9 | 2: 50, 10 | 3: 90, 11 | 4: 205, 12 | 5: 317, 13 | 6: 510, 14 | 7: 610, 15 | 8: 707, 16 | 9: 801, 17 | 10: 1023, 18 | } 19 | -------------------------------------------------------------------------------- /switchbot/const/lock.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class LockStatus(Enum): 7 | LOCKED = 0 8 | UNLOCKED = 1 9 | LOCKING = 2 10 | UNLOCKING = 3 11 | LOCKING_STOP = 4 # LOCKING_BLOCKED 12 | UNLOCKING_STOP = 5 # UNLOCKING_BLOCKED 13 | NOT_FULLY_LOCKED = 6 # LATCH_LOCKED - Only EU lock type 14 | HALF_LOCKED = 7 # Only Lock2 EU lock type 15 | -------------------------------------------------------------------------------- /switchbot/devices/__init__.py: -------------------------------------------------------------------------------- 1 | """Switchbot Device Library.""" 2 | -------------------------------------------------------------------------------- /switchbot/devices/air_purifier.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import struct 7 | from typing import Any 8 | 9 | from bleak.backends.device import BLEDevice 10 | 11 | from ..adv_parsers.air_purifier import get_air_purifier_mode 12 | from ..const import SwitchbotModel 13 | from ..const.air_purifier import AirPurifierMode, AirQualityLevel 14 | from .device import ( 15 | SwitchbotEncryptedDevice, 16 | SwitchbotSequenceDevice, 17 | update_after_operation, 18 | ) 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | COMMAND_HEAD = "570f4c" 24 | COMMAND_TURN_OFF = f"{COMMAND_HEAD}010000" 25 | COMMAND_TURN_ON = f"{COMMAND_HEAD}010100" 26 | COMMAND_SET_MODE = { 27 | AirPurifierMode.LEVEL_1.name.lower(): f"{COMMAND_HEAD}01010100", 28 | AirPurifierMode.LEVEL_2.name.lower(): f"{COMMAND_HEAD}01010132", 29 | AirPurifierMode.LEVEL_3.name.lower(): f"{COMMAND_HEAD}01010164", 30 | AirPurifierMode.AUTO.name.lower(): f"{COMMAND_HEAD}01010200", 31 | AirPurifierMode.SLEEP.name.lower(): f"{COMMAND_HEAD}01010300", 32 | AirPurifierMode.PET.name.lower(): f"{COMMAND_HEAD}01010400", 33 | } 34 | DEVICE_GET_BASIC_SETTINGS_KEY = "570f4d81" 35 | 36 | 37 | class SwitchbotAirPurifier(SwitchbotSequenceDevice, SwitchbotEncryptedDevice): 38 | """Representation of a Switchbot Air Purifier.""" 39 | 40 | def __init__( 41 | self, 42 | device: BLEDevice, 43 | key_id: str, 44 | encryption_key: str, 45 | interface: int = 0, 46 | model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER, 47 | **kwargs: Any, 48 | ) -> None: 49 | super().__init__(device, key_id, encryption_key, model, interface, **kwargs) 50 | 51 | @classmethod 52 | async def verify_encryption_key( 53 | cls, 54 | device: BLEDevice, 55 | key_id: str, 56 | encryption_key: str, 57 | model: SwitchbotModel = SwitchbotModel.AIR_PURIFIER, 58 | **kwargs: Any, 59 | ) -> bool: 60 | return await super().verify_encryption_key( 61 | device, key_id, encryption_key, model, **kwargs 62 | ) 63 | 64 | async def get_basic_info(self) -> dict[str, Any] | None: 65 | """Get device basic settings.""" 66 | if not (_data := await self._get_basic_info()): 67 | return None 68 | 69 | _LOGGER.debug("data: %s", _data) 70 | isOn = bool(_data[2] & 0b10000000) 71 | version_info = (_data[2] & 0b00110000) >> 4 72 | _mode = _data[2] & 0b00000111 73 | isAqiValid = bool(_data[3] & 0b00000100) 74 | child_lock = bool(_data[3] & 0b00000010) 75 | _aqi_level = (_data[4] & 0b00000110) >> 1 76 | aqi_level = AirQualityLevel(_aqi_level).name.lower() 77 | speed = _data[6] & 0b01111111 78 | pm25 = struct.unpack(" bytes | None: 95 | """Return basic info of device.""" 96 | _data = await self._send_command( 97 | key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count 98 | ) 99 | 100 | if _data in (b"\x07", b"\x00"): 101 | _LOGGER.error("Unsuccessful, please try again") 102 | return None 103 | 104 | return _data 105 | 106 | @update_after_operation 107 | async def set_preset_mode(self, preset_mode: str) -> bool: 108 | """Send command to set air purifier preset_mode.""" 109 | result = await self._send_command(COMMAND_SET_MODE[preset_mode]) 110 | return self._check_command_result(result, 0, {1}) 111 | 112 | @update_after_operation 113 | async def turn_on(self) -> bool: 114 | """Turn on the air purifier.""" 115 | result = await self._send_command(COMMAND_TURN_ON) 116 | return self._check_command_result(result, 0, {1}) 117 | 118 | @update_after_operation 119 | async def turn_off(self) -> bool: 120 | """Turn off the air purifier.""" 121 | result = await self._send_command(COMMAND_TURN_OFF) 122 | return self._check_command_result(result, 0, {1}) 123 | 124 | def get_current_percentage(self) -> Any: 125 | """Return cached percentage.""" 126 | return self._get_adv_value("speed") 127 | 128 | def is_on(self) -> bool | None: 129 | """Return air purifier state from cache.""" 130 | return self._get_adv_value("isOn") 131 | 132 | def get_current_aqi_level(self) -> Any: 133 | """Return cached aqi level.""" 134 | return self._get_adv_value("aqi_level") 135 | 136 | def get_current_pm25(self) -> Any: 137 | """Return cached pm25.""" 138 | return self._get_adv_value("pm25") 139 | 140 | def get_current_mode(self) -> Any: 141 | """Return cached mode.""" 142 | return self._get_adv_value("mode") 143 | -------------------------------------------------------------------------------- /switchbot/devices/base_cover.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from abc import abstractmethod 7 | from typing import Any 8 | 9 | from .device import REQ_HEADER, SwitchbotDevice, update_after_operation 10 | 11 | # Cover keys 12 | COVER_COMMAND = "4501" 13 | ROLLERSHADE_COMMAND = "4701" 14 | CONTROL_SOURCE = "00" 15 | 16 | # For second element of open and close arrs we should add two bytes i.e. ff00 17 | # First byte [ff] stands for speed (00 or ff - normal, 01 - slow) * 18 | # * Only for curtains 3. For other models use ff 19 | # Second byte [00] is a command (00 - open, 64 - close) 20 | POSITION_KEYS = [ 21 | f"{REQ_HEADER}{COVER_COMMAND}0101", 22 | f"{REQ_HEADER}{COVER_COMMAND}05", # +speed 23 | ] # +actual_position 24 | STOP_KEYS = [f"{REQ_HEADER}{COVER_COMMAND}0001", f"{REQ_HEADER}{COVER_COMMAND}00ff"] 25 | 26 | COVER_EXT_SUM_KEY = f"{REQ_HEADER}460401" 27 | COVER_EXT_ADV_KEY = f"{REQ_HEADER}460402" 28 | 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | class SwitchbotBaseCover(SwitchbotDevice): 34 | """Representation of a Switchbot Cover devices for both curtains and tilt blinds.""" 35 | 36 | def __init__(self, reverse: bool, *args: Any, **kwargs: Any) -> None: 37 | """Switchbot Cover device constructor.""" 38 | super().__init__(*args, **kwargs) 39 | self._reverse = reverse 40 | self._settings: dict[str, Any] = {} 41 | self.ext_info_sum: dict[str, Any] = {} 42 | self.ext_info_adv: dict[str, Any] = {} 43 | self._is_opening: bool = False 44 | self._is_closing: bool = False 45 | 46 | async def _send_multiple_commands(self, keys: list[str]) -> bool: 47 | """ 48 | Send multiple commands to device. 49 | 50 | Since we current have no way to tell which command the device 51 | needs we send both. 52 | """ 53 | final_result = False 54 | for key in keys: 55 | result = await self._send_command(key) 56 | final_result |= self._check_command_result(result, 0, {1}) 57 | return final_result 58 | 59 | @update_after_operation 60 | async def stop(self) -> bool: 61 | """Send stop command to device.""" 62 | return await self._send_multiple_commands(STOP_KEYS) 63 | 64 | @update_after_operation 65 | async def set_position(self, position: int, speed: int = 255) -> bool: 66 | """Send position command (0-100) to device. Speed 255 - normal, 1 - slow""" 67 | position = (100 - position) if self._reverse else position 68 | return await self._send_multiple_commands( 69 | [ 70 | f"{POSITION_KEYS[0]}{position:02X}", 71 | f"{POSITION_KEYS[1]}{speed:02X}{position:02X}", 72 | ] 73 | ) 74 | 75 | @abstractmethod 76 | def get_position(self) -> Any: 77 | """Return current device position.""" 78 | 79 | @abstractmethod 80 | async def get_basic_info(self) -> dict[str, Any] | None: 81 | """Get device basic settings.""" 82 | 83 | @abstractmethod 84 | async def get_extended_info_summary(self) -> dict[str, Any] | None: 85 | """Get extended info for all devices in chain.""" 86 | 87 | async def get_extended_info_adv(self) -> dict[str, Any] | None: 88 | """Get advance page info for device chain.""" 89 | _data = await self._send_command(key=COVER_EXT_ADV_KEY) 90 | if not _data: 91 | _LOGGER.error("%s: Unsuccessful, no result from device", self.name) 92 | return None 93 | 94 | if _data in (b"\x07", b"\x00"): 95 | _LOGGER.error("%s: Unsuccessful, please try again", self.name) 96 | return None 97 | 98 | _state_of_charge = [ 99 | "not_charging", 100 | "charging_by_adapter", 101 | "charging_by_solar", 102 | "fully_charged", 103 | "solar_not_charging", 104 | "charging_error", 105 | ] 106 | 107 | self.ext_info_adv["device0"] = { 108 | "battery": _data[1], 109 | "firmware": _data[2] / 10.0, 110 | "stateOfCharge": _state_of_charge[_data[3]], 111 | } 112 | 113 | # If grouped curtain device present. 114 | if _data[4]: 115 | self.ext_info_adv["device1"] = { 116 | "battery": _data[4], 117 | "firmware": _data[5] / 10.0, 118 | "stateOfCharge": _state_of_charge[_data[6]], 119 | } 120 | 121 | return self.ext_info_adv 122 | 123 | def get_light_level(self) -> Any: 124 | """Return cached light level.""" 125 | # To get actual light level call update() first. 126 | return self._get_adv_value("lightLevel") 127 | 128 | def is_reversed(self) -> bool: 129 | """Return True if curtain position is opposite from SB data.""" 130 | return self._reverse 131 | 132 | def is_calibrated(self) -> Any: 133 | """Return True curtain is calibrated.""" 134 | # To get actual light level call update() first. 135 | return self._get_adv_value("calibration") 136 | 137 | def is_opening(self) -> bool: 138 | """Return True if the curtain is opening.""" 139 | return self._is_opening 140 | 141 | def is_closing(self) -> bool: 142 | """Return True if the curtain is closing.""" 143 | return self._is_closing 144 | -------------------------------------------------------------------------------- /switchbot/devices/base_light.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from abc import abstractmethod 6 | from typing import Any 7 | 8 | from ..helpers import create_background_task 9 | from ..models import SwitchBotAdvertisement 10 | from .device import ColorMode, SwitchbotDevice 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class SwitchbotBaseLight(SwitchbotDevice): 16 | """Representation of a Switchbot light.""" 17 | 18 | def __init__(self, *args: Any, **kwargs: Any) -> None: 19 | """Switchbot bulb constructor.""" 20 | super().__init__(*args, **kwargs) 21 | self._state: dict[str, Any] = {} 22 | 23 | @property 24 | def on(self) -> bool | None: 25 | """Return if bulb is on.""" 26 | return self.is_on() 27 | 28 | @property 29 | def rgb(self) -> tuple[int, int, int] | None: 30 | """Return the current rgb value.""" 31 | if "r" not in self._state or "g" not in self._state or "b" not in self._state: 32 | return None 33 | return self._state["r"], self._state["g"], self._state["b"] 34 | 35 | @property 36 | def color_temp(self) -> int | None: 37 | """Return the current color temp value.""" 38 | return self._state.get("cw") or self.min_temp 39 | 40 | @property 41 | def brightness(self) -> int | None: 42 | """Return the current brightness value.""" 43 | return self._get_adv_value("brightness") or 0 44 | 45 | @property 46 | def color_mode(self) -> ColorMode: 47 | """Return the current color mode.""" 48 | return ColorMode(self._get_adv_value("color_mode") or 0) 49 | 50 | @property 51 | def min_temp(self) -> int: 52 | """Return minimum color temp.""" 53 | return 2700 54 | 55 | @property 56 | def max_temp(self) -> int: 57 | """Return maximum color temp.""" 58 | return 6500 59 | 60 | def is_on(self) -> bool | None: 61 | """Return bulb state from cache.""" 62 | return self._get_adv_value("isOn") 63 | 64 | @abstractmethod 65 | async def turn_on(self) -> bool: 66 | """Turn device on.""" 67 | 68 | @abstractmethod 69 | async def turn_off(self) -> bool: 70 | """Turn device off.""" 71 | 72 | @abstractmethod 73 | async def set_brightness(self, brightness: int) -> bool: 74 | """Set brightness.""" 75 | 76 | @abstractmethod 77 | async def set_color_temp(self, brightness: int, color_temp: int) -> bool: 78 | """Set color temp.""" 79 | 80 | @abstractmethod 81 | async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: 82 | """Set rgb.""" 83 | 84 | def poll_needed(self, last_poll_time: float | None) -> bool: 85 | """Return if poll is needed.""" 86 | return False 87 | 88 | async def update(self) -> None: 89 | """Update device data.""" 90 | self._last_full_update = time.monotonic() 91 | 92 | 93 | class SwitchbotSequenceBaseLight(SwitchbotBaseLight): 94 | """Representation of a Switchbot light.""" 95 | 96 | def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: 97 | """Update device data from advertisement.""" 98 | current_state = self._get_adv_value("sequence_number") 99 | super().update_from_advertisement(advertisement) 100 | new_state = self._get_adv_value("sequence_number") 101 | _LOGGER.debug( 102 | "%s: update advertisement: %s (seq before: %s) (seq after: %s)", 103 | self.name, 104 | advertisement, 105 | current_state, 106 | new_state, 107 | ) 108 | if current_state != new_state: 109 | create_background_task(self.update()) 110 | -------------------------------------------------------------------------------- /switchbot/devices/blind_tilt.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from switchbot.devices.device import ( 9 | REQ_HEADER, 10 | SwitchbotSequenceDevice, 11 | update_after_operation, 12 | ) 13 | 14 | from ..models import SwitchBotAdvertisement 15 | from .base_cover import COVER_COMMAND, COVER_EXT_SUM_KEY, SwitchbotBaseCover 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | OPEN_KEYS = [ 21 | f"{REQ_HEADER}{COVER_COMMAND}010132", 22 | f"{REQ_HEADER}{COVER_COMMAND}05ff32", 23 | ] 24 | CLOSE_DOWN_KEYS = [ 25 | f"{REQ_HEADER}{COVER_COMMAND}010100", 26 | f"{REQ_HEADER}{COVER_COMMAND}05ff00", 27 | ] 28 | CLOSE_UP_KEYS = [ 29 | f"{REQ_HEADER}{COVER_COMMAND}010164", 30 | f"{REQ_HEADER}{COVER_COMMAND}05ff64", 31 | ] 32 | 33 | 34 | class SwitchbotBlindTilt(SwitchbotBaseCover, SwitchbotSequenceDevice): 35 | """Representation of a Switchbot Blind Tilt.""" 36 | 37 | # The position of the blind is saved returned with 0 = closed down, 50 = open and 100 = closed up. 38 | # This is independent of the calibration of the blind. 39 | # The parameter 'reverse_mode' reverse these values, 40 | # if 'reverse_mode' = True, position = 0 equals closed up 41 | # and position = 100 equals closed down. The parameter is default set to False so that 42 | # the definition of position is the same as in Home Assistant. 43 | # This is opposite to the base class so needs to be overwritten. 44 | 45 | def __init__(self, *args: Any, **kwargs: Any) -> None: 46 | """Switchbot Blind Tilt/woBlindTilt constructor.""" 47 | self._reverse: bool = kwargs.pop("reverse_mode", False) 48 | super().__init__(self._reverse, *args, **kwargs) 49 | 50 | def _set_parsed_data( 51 | self, advertisement: SwitchBotAdvertisement, data: dict[str, Any] 52 | ) -> None: 53 | """Set data.""" 54 | in_motion = data["inMotion"] 55 | previous_tilt = self._get_adv_value("tilt") 56 | new_tilt = data["tilt"] 57 | self._update_motion_direction(in_motion, previous_tilt, new_tilt) 58 | super()._set_parsed_data(advertisement, data) 59 | 60 | def _update_motion_direction( 61 | self, in_motion: bool, previous_tilt: int | None, new_tilt: int 62 | ) -> None: 63 | """Update opening/closing status based on movement.""" 64 | if previous_tilt is None: 65 | return 66 | if in_motion is False: 67 | self._is_closing = self._is_opening = False 68 | return 69 | 70 | if new_tilt != previous_tilt: 71 | self._is_opening = new_tilt > previous_tilt 72 | self._is_closing = new_tilt < previous_tilt 73 | 74 | @update_after_operation 75 | async def open(self) -> bool: 76 | """Send open command.""" 77 | self._is_opening = True 78 | self._is_closing = False 79 | return await self._send_multiple_commands(OPEN_KEYS) 80 | 81 | @update_after_operation 82 | async def close_up(self) -> bool: 83 | """Send close up command.""" 84 | self._is_opening = False 85 | self._is_closing = True 86 | return await self._send_multiple_commands(CLOSE_UP_KEYS) 87 | 88 | @update_after_operation 89 | async def close_down(self) -> bool: 90 | """Send close down command.""" 91 | self._is_opening = False 92 | self._is_closing = True 93 | return await self._send_multiple_commands(CLOSE_DOWN_KEYS) 94 | 95 | # The aim of this is to close to the nearest endpoint. 96 | # If we're open upwards we close up, if we're open downwards we close down. 97 | # If we're in the middle we default to close down as that seems to be the app's preference. 98 | @update_after_operation 99 | async def close(self) -> bool: 100 | """Send close command.""" 101 | if self.get_position() > 50: 102 | return await self.close_up() 103 | return await self.close_down() 104 | 105 | def get_position(self) -> Any: 106 | """Return cached tilt (0-100) of Blind Tilt.""" 107 | # To get actual tilt call update() first. 108 | return self._get_adv_value("tilt") 109 | 110 | async def get_basic_info(self) -> dict[str, Any] | None: 111 | """Get device basic settings.""" 112 | if not (_data := await self._get_basic_info()): 113 | return None 114 | 115 | _tilt = max(min(_data[6], 100), 0) 116 | _moving = bool(_data[5] & 0b00000011) 117 | if _moving: 118 | _opening = bool(_data[5] & 0b00000010) 119 | _closing = not _opening and bool(_data[5] & 0b00000001) 120 | if _opening: 121 | _flag = bool(_data[5] & 0b00000001) 122 | _up = _flag if self._reverse else not _flag 123 | else: 124 | _up = _tilt < 50 if self._reverse else _tilt > 50 125 | 126 | return { 127 | "battery": _data[1], 128 | "firmware": _data[2] / 10.0, 129 | "light": bool(_data[4] & 0b00100000), 130 | "fault": bool(_data[4] & 0b00001000), 131 | "solarPanel": bool(_data[5] & 0b00001000), 132 | "calibration": bool(_data[5] & 0b00000100), 133 | "calibrated": bool(_data[5] & 0b00000100), 134 | "inMotion": _moving, 135 | "motionDirection": { 136 | "opening": _moving and _opening, 137 | "closing": _moving and _closing, 138 | "up": _moving and _up, 139 | "down": _moving and not _up, 140 | }, 141 | "tilt": (100 - _tilt) if self._reverse else _tilt, 142 | "timers": _data[7], 143 | } 144 | 145 | async def get_extended_info_summary(self) -> dict[str, Any] | None: 146 | """Get extended info for all devices in chain.""" 147 | _data = await self._send_command(key=COVER_EXT_SUM_KEY) 148 | 149 | if not _data: 150 | _LOGGER.error("%s: Unsuccessful, no result from device", self.name) 151 | return None 152 | 153 | if _data in (b"\x07", b"\x00"): 154 | _LOGGER.error("%s: Unsuccessful, please try again", self.name) 155 | return None 156 | 157 | self.ext_info_sum["device0"] = { 158 | "light": bool(_data[1] & 0b00100000), 159 | } 160 | 161 | return self.ext_info_sum 162 | -------------------------------------------------------------------------------- /switchbot/devices/bot.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from .device import ( 9 | DEVICE_SET_EXTENDED_KEY, 10 | DEVICE_SET_MODE_KEY, 11 | SwitchbotDeviceOverrideStateDuringConnection, 12 | update_after_operation, 13 | ) 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | BOT_COMMAND_HEADER = "5701" 18 | 19 | # Bot keys 20 | PRESS_KEY = f"{BOT_COMMAND_HEADER}00" 21 | ON_KEY = f"{BOT_COMMAND_HEADER}01" 22 | OFF_KEY = f"{BOT_COMMAND_HEADER}02" 23 | DOWN_KEY = f"{BOT_COMMAND_HEADER}03" 24 | UP_KEY = f"{BOT_COMMAND_HEADER}04" 25 | 26 | 27 | class Switchbot(SwitchbotDeviceOverrideStateDuringConnection): 28 | """Representation of a Switchbot.""" 29 | 30 | def __init__(self, *args: Any, **kwargs: Any) -> None: 31 | """Switchbot Bot/WoHand constructor.""" 32 | super().__init__(*args, **kwargs) 33 | self._inverse: bool = kwargs.pop("inverse_mode", False) 34 | 35 | @update_after_operation 36 | async def turn_on(self) -> bool: 37 | """Turn device on.""" 38 | result = await self._send_command(ON_KEY) 39 | ret = self._check_command_result(result, 0, {1, 5}) 40 | self._override_state({"isOn": True}) 41 | _LOGGER.debug( 42 | "%s: Turn on result: %s -> %s", 43 | self.name, 44 | result.hex() if result else None, 45 | self._override_adv_data, 46 | ) 47 | self._fire_callbacks() 48 | return ret 49 | 50 | @update_after_operation 51 | async def turn_off(self) -> bool: 52 | """Turn device off.""" 53 | result = await self._send_command(OFF_KEY) 54 | ret = self._check_command_result(result, 0, {1, 5}) 55 | self._override_state({"isOn": False}) 56 | _LOGGER.debug( 57 | "%s: Turn off result: %s -> %s", 58 | self.name, 59 | result.hex() if result else None, 60 | self._override_adv_data, 61 | ) 62 | self._fire_callbacks() 63 | return ret 64 | 65 | @update_after_operation 66 | async def hand_up(self) -> bool: 67 | """Raise device arm.""" 68 | result = await self._send_command(UP_KEY) 69 | return self._check_command_result(result, 0, {1, 5}) 70 | 71 | @update_after_operation 72 | async def hand_down(self) -> bool: 73 | """Lower device arm.""" 74 | result = await self._send_command(DOWN_KEY) 75 | return self._check_command_result(result, 0, {1, 5}) 76 | 77 | @update_after_operation 78 | async def press(self) -> bool: 79 | """Press command to device.""" 80 | result = await self._send_command(PRESS_KEY) 81 | return self._check_command_result(result, 0, {1, 5}) 82 | 83 | @update_after_operation 84 | async def set_switch_mode( 85 | self, switch_mode: bool = False, strength: int = 100, inverse: bool = False 86 | ) -> bool: 87 | """Change bot mode.""" 88 | mode_key = format(switch_mode, "b") + format(inverse, "b") 89 | strength_key = f"{strength:0{2}x}" # to hex with padding to double digit 90 | result = await self._send_command(DEVICE_SET_MODE_KEY + strength_key + mode_key) 91 | return self._check_command_result(result, 0, {1}) 92 | 93 | @update_after_operation 94 | async def set_long_press(self, duration: int = 0) -> bool: 95 | """Set bot long press duration.""" 96 | duration_key = f"{duration:0{2}x}" # to hex with padding to double digit 97 | result = await self._send_command(DEVICE_SET_EXTENDED_KEY + "08" + duration_key) 98 | return self._check_command_result(result, 0, {1}) 99 | 100 | async def get_basic_info(self) -> dict[str, Any] | None: 101 | """Get device basic settings.""" 102 | if not (_data := await self._get_basic_info()): 103 | return None 104 | return { 105 | "battery": _data[1], 106 | "firmware": _data[2] / 10.0, 107 | "strength": _data[3], 108 | "timers": _data[8], 109 | "switchMode": bool(_data[9] & 16), 110 | "inverseDirection": bool(_data[9] & 1), 111 | "holdSeconds": _data[10], 112 | } 113 | 114 | def is_on(self) -> bool | None: 115 | """Return switch state from cache.""" 116 | # To get actual position call update() first. 117 | value = self._get_adv_value("isOn") 118 | if value is None: 119 | return None 120 | 121 | if self._inverse: 122 | return not value 123 | return value 124 | -------------------------------------------------------------------------------- /switchbot/devices/bulb.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from .base_light import SwitchbotSequenceBaseLight 6 | from .device import REQ_HEADER, ColorMode 7 | 8 | BULB_COMMAND_HEADER = "4701" 9 | BULB_REQUEST = f"{REQ_HEADER}4801" 10 | 11 | BULB_COMMAND = f"{REQ_HEADER}{BULB_COMMAND_HEADER}" 12 | # Bulb keys 13 | BULB_ON_KEY = f"{BULB_COMMAND}01" 14 | BULB_OFF_KEY = f"{BULB_COMMAND}02" 15 | RGB_BRIGHTNESS_KEY = f"{BULB_COMMAND}12" 16 | CW_BRIGHTNESS_KEY = f"{BULB_COMMAND}13" 17 | BRIGHTNESS_KEY = f"{BULB_COMMAND}14" 18 | RGB_KEY = f"{BULB_COMMAND}16" 19 | CW_KEY = f"{BULB_COMMAND}17" 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class SwitchbotBulb(SwitchbotSequenceBaseLight): 25 | """Representation of a Switchbot bulb.""" 26 | 27 | @property 28 | def color_modes(self) -> set[ColorMode]: 29 | """Return the supported color modes.""" 30 | return {ColorMode.RGB, ColorMode.COLOR_TEMP} 31 | 32 | async def update(self) -> None: 33 | """Update state of device.""" 34 | result = await self._send_command(BULB_REQUEST) 35 | self._update_state(result) 36 | await super().update() 37 | 38 | async def turn_on(self) -> bool: 39 | """Turn device on.""" 40 | result = await self._send_command(BULB_ON_KEY) 41 | self._update_state(result) 42 | return self._check_command_result(result, 1, {0x80}) 43 | 44 | async def turn_off(self) -> bool: 45 | """Turn device off.""" 46 | result = await self._send_command(BULB_OFF_KEY) 47 | self._update_state(result) 48 | return self._check_command_result(result, 1, {0x00}) 49 | 50 | async def set_brightness(self, brightness: int) -> bool: 51 | """Set brightness.""" 52 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 53 | result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}") 54 | self._update_state(result) 55 | return self._check_command_result(result, 1, {0x80}) 56 | 57 | async def set_color_temp(self, brightness: int, color_temp: int) -> bool: 58 | """Set color temp.""" 59 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 60 | assert 2700 <= color_temp <= 6500, "Color Temp must be between 0 and 100" 61 | result = await self._send_command( 62 | f"{CW_BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}" 63 | ) 64 | self._update_state(result) 65 | return self._check_command_result(result, 1, {0x80}) 66 | 67 | async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: 68 | """Set rgb.""" 69 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 70 | assert 0 <= r <= 255, "r must be between 0 and 255" 71 | assert 0 <= g <= 255, "g must be between 0 and 255" 72 | assert 0 <= b <= 255, "b must be between 0 and 255" 73 | result = await self._send_command( 74 | f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}" 75 | ) 76 | self._update_state(result) 77 | return self._check_command_result(result, 1, {0x80}) 78 | 79 | def _update_state(self, result: bytes | None) -> None: 80 | """Update device state.""" 81 | if not result or len(result) < 10: 82 | return 83 | self._state["r"] = result[3] 84 | self._state["g"] = result[4] 85 | self._state["b"] = result[5] 86 | self._state["cw"] = int(result[6:8].hex(), 16) 87 | self._override_state( 88 | { 89 | "isOn": result[1] == 0x80, 90 | "color_mode": result[10], 91 | } 92 | ) 93 | _LOGGER.debug("%s: update state: %s = %s", self.name, result.hex(), self._state) 94 | self._fire_callbacks() 95 | -------------------------------------------------------------------------------- /switchbot/devices/ceiling_light.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from .base_light import SwitchbotBaseLight 6 | from .device import REQ_HEADER, ColorMode 7 | 8 | CEILING_LIGHT_COMMAND_HEADER = "5401" 9 | CEILING_LIGHT_REQUEST = f"{REQ_HEADER}5501" 10 | 11 | CEILING_LIGHT_COMMAND = f"{REQ_HEADER}{CEILING_LIGHT_COMMAND_HEADER}" 12 | CEILING_LIGHT_ON_KEY = f"{CEILING_LIGHT_COMMAND}01FF01FFFF" 13 | CEILING_LIGHT_OFF_KEY = f"{CEILING_LIGHT_COMMAND}02FF01FFFF" 14 | CW_BRIGHTNESS_KEY = f"{CEILING_LIGHT_COMMAND}010001" 15 | BRIGHTNESS_KEY = f"{CEILING_LIGHT_COMMAND}01FF01" 16 | 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class SwitchbotCeilingLight(SwitchbotBaseLight): 22 | """Representation of a Switchbot bulb.""" 23 | 24 | @property 25 | def color_modes(self) -> set[ColorMode]: 26 | """Return the supported color modes.""" 27 | return {ColorMode.COLOR_TEMP} 28 | 29 | async def turn_on(self) -> bool: 30 | """Turn device on.""" 31 | result = await self._send_command(CEILING_LIGHT_ON_KEY) 32 | ret = self._check_command_result(result, 0, {0x01}) 33 | self._override_state({"isOn": True}) 34 | self._fire_callbacks() 35 | return ret 36 | 37 | async def turn_off(self) -> bool: 38 | """Turn device off.""" 39 | result = await self._send_command(CEILING_LIGHT_OFF_KEY) 40 | ret = self._check_command_result(result, 0, {0x01}) 41 | self._override_state({"isOn": False}) 42 | self._fire_callbacks() 43 | return ret 44 | 45 | async def set_brightness(self, brightness: int) -> bool: 46 | """Set brightness.""" 47 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 48 | result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}0FA1") 49 | ret = self._check_command_result(result, 0, {0x01}) 50 | self._override_state({"brightness": brightness, "isOn": True}) 51 | self._fire_callbacks() 52 | return ret 53 | 54 | async def set_color_temp(self, brightness: int, color_temp: int) -> bool: 55 | """Set color temp.""" 56 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 57 | assert 2700 <= color_temp <= 6500, "Color Temp must be between 0 and 100" 58 | result = await self._send_command( 59 | f"{CW_BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}" 60 | ) 61 | ret = self._check_command_result(result, 0, {0x01}) 62 | self._state["cw"] = color_temp 63 | self._override_state({"brightness": brightness, "isOn": True}) 64 | self._fire_callbacks() 65 | return ret 66 | 67 | async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: 68 | """Set rgb.""" 69 | # Not supported on this device 70 | -------------------------------------------------------------------------------- /switchbot/devices/contact.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /switchbot/devices/curtain.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from ..models import SwitchBotAdvertisement 9 | from .base_cover import COVER_COMMAND, COVER_EXT_SUM_KEY, SwitchbotBaseCover 10 | from .device import REQ_HEADER, update_after_operation 11 | 12 | # For second element of open and close arrs we should add two bytes i.e. ff00 13 | # First byte [ff] stands for speed (00 or ff - normal, 01 - slow) * 14 | # * Only for curtains 3. For other models use ff 15 | # Second byte [00] is a command (00 - open, 64 - close) 16 | OPEN_KEYS = [ 17 | f"{REQ_HEADER}{COVER_COMMAND}010100", 18 | f"{REQ_HEADER}{COVER_COMMAND}05", # +speed + "00" 19 | ] 20 | CLOSE_KEYS = [ 21 | f"{REQ_HEADER}{COVER_COMMAND}010164", 22 | f"{REQ_HEADER}{COVER_COMMAND}05", # +speed + "64" 23 | ] 24 | POSITION_KEYS = [ 25 | f"{REQ_HEADER}{COVER_COMMAND}0101", 26 | f"{REQ_HEADER}{COVER_COMMAND}05", # +speed 27 | ] # +actual_position 28 | STOP_KEYS = [f"{REQ_HEADER}{COVER_COMMAND}0001", f"{REQ_HEADER}{COVER_COMMAND}00ff"] 29 | 30 | CURTAIN_EXT_CHAIN_INFO_KEY = f"{REQ_HEADER}468101" 31 | 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | 35 | 36 | class SwitchbotCurtain(SwitchbotBaseCover): 37 | """Representation of a Switchbot Curtain.""" 38 | 39 | def __init__(self, *args: Any, **kwargs: Any) -> None: 40 | """Switchbot Curtain/WoCurtain constructor.""" 41 | # The position of the curtain is saved returned with 0 = open and 100 = closed. 42 | # This is independent of the calibration of the curtain bot (Open left to right/ 43 | # Open right to left/Open from the middle). 44 | # The parameter 'reverse_mode' reverse these values, 45 | # if 'reverse_mode' = True, position = 0 equals close 46 | # and position = 100 equals open. The parameter is default set to True so that 47 | # the definition of position is the same as in Home Assistant. 48 | 49 | self._reverse: bool = kwargs.pop("reverse_mode", True) 50 | super().__init__(self._reverse, *args, **kwargs) 51 | self._settings: dict[str, Any] = {} 52 | self.ext_info_sum: dict[str, Any] = {} 53 | self.ext_info_adv: dict[str, Any] = {} 54 | 55 | def _set_parsed_data( 56 | self, advertisement: SwitchBotAdvertisement, data: dict[str, Any] 57 | ) -> None: 58 | """Set data.""" 59 | in_motion = data["inMotion"] 60 | previous_position = self._get_adv_value("position") 61 | new_position = data["position"] 62 | self._update_motion_direction(in_motion, previous_position, new_position) 63 | super()._set_parsed_data(advertisement, data) 64 | 65 | @update_after_operation 66 | async def open(self, speed: int = 255) -> bool: 67 | """Send open command. Speed 255 - normal, 1 - slow""" 68 | self._is_opening = True 69 | self._is_closing = False 70 | return await self._send_multiple_commands( 71 | [OPEN_KEYS[0], f"{OPEN_KEYS[1]}{speed:02X}00"] 72 | ) 73 | 74 | @update_after_operation 75 | async def close(self, speed: int = 255) -> bool: 76 | """Send close command. Speed 255 - normal, 1 - slow""" 77 | self._is_closing = True 78 | self._is_opening = False 79 | return await self._send_multiple_commands( 80 | [CLOSE_KEYS[0], f"{CLOSE_KEYS[1]}{speed:02X}64"] 81 | ) 82 | 83 | @update_after_operation 84 | async def stop(self) -> bool: 85 | """Send stop command to device.""" 86 | self._is_opening = self._is_closing = False 87 | return await super().stop() 88 | 89 | @update_after_operation 90 | async def set_position(self, position: int, speed: int = 255) -> bool: 91 | """Send position command (0-100) to device. Speed 255 - normal, 1 - slow""" 92 | direction_adjusted_position = (100 - position) if self._reverse else position 93 | self._update_motion_direction( 94 | True, self._get_adv_value("position"), direction_adjusted_position 95 | ) 96 | return await super().set_position(position, speed) 97 | 98 | def get_position(self) -> Any: 99 | """Return cached position (0-100) of Curtain.""" 100 | # To get actual position call update() first. 101 | return self._get_adv_value("position") 102 | 103 | async def get_basic_info(self) -> dict[str, Any] | None: 104 | """Get device basic settings.""" 105 | if not (_data := await self._get_basic_info()): 106 | return None 107 | 108 | _position = max(min(_data[6], 100), 0) 109 | _direction_adjusted_position = (100 - _position) if self._reverse else _position 110 | _previous_position = self._get_adv_value("position") 111 | _in_motion = bool(_data[5] & 0b01000011) 112 | self._update_motion_direction( 113 | _in_motion, _previous_position, _direction_adjusted_position 114 | ) 115 | 116 | return { 117 | "battery": _data[1], 118 | "firmware": _data[2] / 10.0, 119 | "chainLength": _data[3], 120 | "openDirection": ( 121 | "right_to_left" if _data[4] & 0b10000000 == 128 else "left_to_right" 122 | ), 123 | "touchToOpen": bool(_data[4] & 0b01000000), 124 | "light": bool(_data[4] & 0b00100000), 125 | "fault": bool(_data[4] & 0b00001000), 126 | "solarPanel": bool(_data[5] & 0b00001000), 127 | "calibration": bool(_data[5] & 0b00000100), 128 | "calibrated": bool(_data[5] & 0b00000100), 129 | "inMotion": _in_motion, 130 | "position": _direction_adjusted_position, 131 | "timers": _data[7], 132 | } 133 | 134 | def _update_motion_direction( 135 | self, in_motion: bool, previous_position: int | None, new_position: int 136 | ) -> None: 137 | """Update opening/closing status based on movement.""" 138 | if previous_position is None: 139 | return 140 | if in_motion is False: 141 | self._is_closing = self._is_opening = False 142 | return 143 | 144 | if new_position != previous_position: 145 | self._is_opening = new_position > previous_position 146 | self._is_closing = new_position < previous_position 147 | 148 | async def get_extended_info_summary(self) -> dict[str, Any] | None: 149 | """Get extended info for all devices in chain.""" 150 | _data = await self._send_command(key=COVER_EXT_SUM_KEY) 151 | 152 | if not _data: 153 | _LOGGER.error("%s: Unsuccessful, no result from device", self.name) 154 | return None 155 | 156 | if _data in (b"\x07", b"\x00"): 157 | _LOGGER.error("%s: Unsuccessful, please try again", self.name) 158 | return None 159 | 160 | self.ext_info_sum["device0"] = { 161 | "openDirectionDefault": not bool(_data[1] & 0b10000000), 162 | "touchToOpen": bool(_data[1] & 0b01000000), 163 | "light": bool(_data[1] & 0b00100000), 164 | "openDirection": ( 165 | "left_to_right" if _data[1] & 0b00010000 else "right_to_left" 166 | ), 167 | } 168 | 169 | # if grouped curtain device present. 170 | if _data[2] != 0: 171 | self.ext_info_sum["device1"] = { 172 | "openDirectionDefault": not bool(_data[2] & 0b10000000), 173 | "touchToOpen": bool(_data[2] & 0b01000000), 174 | "light": bool(_data[2] & 0b00100000), 175 | "openDirection": ( 176 | "left_to_right" if _data[2] & 0b00010000 else "right_to_left" 177 | ), 178 | } 179 | 180 | return self.ext_info_sum 181 | -------------------------------------------------------------------------------- /switchbot/devices/evaporative_humidifier.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from bleak.backends.device import BLEDevice 5 | 6 | from ..adv_parsers.humidifier import calculate_temperature_and_humidity 7 | from ..const import SwitchbotModel 8 | from ..const.evaporative_humidifier import ( 9 | TARGET_HUMIDITY_MODES, 10 | HumidifierAction, 11 | HumidifierMode, 12 | HumidifierWaterLevel, 13 | ) 14 | from .device import ( 15 | SwitchbotEncryptedDevice, 16 | SwitchbotOperationError, 17 | SwitchbotSequenceDevice, 18 | update_after_operation, 19 | ) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | COMMAND_HEADER = "57" 24 | COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103" 25 | COMMAND_TURN_ON = f"{COMMAND_HEADER}0f430101" 26 | COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f430100" 27 | COMMAND_CHILD_LOCK_ON = f"{COMMAND_HEADER}0f430501" 28 | COMMAND_CHILD_LOCK_OFF = f"{COMMAND_HEADER}0f430500" 29 | COMMAND_AUTO_DRY_ON = f"{COMMAND_HEADER}0f430a01" 30 | COMMAND_AUTO_DRY_OFF = f"{COMMAND_HEADER}0f430a02" 31 | COMMAND_SET_MODE = f"{COMMAND_HEADER}0f4302" 32 | COMMAND_GET_BASIC_INFO = f"{COMMAND_HEADER}000300" 33 | COMMAND_SET_DRYING_FILTER = f"{COMMAND_TURN_ON}08" 34 | 35 | MODES_COMMANDS = { 36 | HumidifierMode.HIGH: "010100", 37 | HumidifierMode.MEDIUM: "010200", 38 | HumidifierMode.LOW: "010300", 39 | HumidifierMode.QUIET: "010400", 40 | HumidifierMode.TARGET_HUMIDITY: "0200", 41 | HumidifierMode.SLEEP: "0300", 42 | HumidifierMode.AUTO: "040000", 43 | } 44 | 45 | DEVICE_GET_BASIC_SETTINGS_KEY = "570f4481" 46 | 47 | 48 | class SwitchbotEvaporativeHumidifier(SwitchbotSequenceDevice, SwitchbotEncryptedDevice): 49 | """Representation of a Switchbot Evaporative Humidifier""" 50 | 51 | def __init__( 52 | self, 53 | device: BLEDevice, 54 | key_id: str, 55 | encryption_key: str, 56 | interface: int = 0, 57 | model: SwitchbotModel = SwitchbotModel.EVAPORATIVE_HUMIDIFIER, 58 | **kwargs: Any, 59 | ) -> None: 60 | self._force_next_update = False 61 | super().__init__(device, key_id, encryption_key, model, interface, **kwargs) 62 | 63 | @classmethod 64 | async def verify_encryption_key( 65 | cls, 66 | device: BLEDevice, 67 | key_id: str, 68 | encryption_key: str, 69 | model: SwitchbotModel = SwitchbotModel.EVAPORATIVE_HUMIDIFIER, 70 | **kwargs: Any, 71 | ) -> bool: 72 | return await super().verify_encryption_key( 73 | device, key_id, encryption_key, model, **kwargs 74 | ) 75 | 76 | async def get_basic_info(self) -> dict[str, Any] | None: 77 | """Get device basic settings.""" 78 | if not (_data := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)): 79 | return None 80 | 81 | _LOGGER.debug("basic info data: %s", _data.hex()) 82 | isOn = bool(_data[1] & 0b10000000) 83 | mode = HumidifierMode(_data[1] & 0b00001111) 84 | over_humidify_protection = bool(_data[2] & 0b10000000) 85 | child_lock = bool(_data[2] & 0b00100000) 86 | tank_removed = bool(_data[2] & 0b00000100) 87 | tilted_alert = bool(_data[2] & 0b00000010) 88 | filter_missing = bool(_data[2] & 0b00000001) 89 | is_meter_binded = bool(_data[3] & 0b10000000) 90 | 91 | _temp_c, _temp_f, humidity = calculate_temperature_and_humidity( 92 | _data[3:6], is_meter_binded 93 | ) 94 | 95 | water_level = HumidifierWaterLevel(_data[5] & 0b00000011).name.lower() 96 | filter_run_time = int.from_bytes(_data[6:8], byteorder="big") & 0xFFF 97 | target_humidity = _data[10] & 0b01111111 98 | 99 | return { 100 | "isOn": isOn, 101 | "mode": mode, 102 | "over_humidify_protection": over_humidify_protection, 103 | "child_lock": child_lock, 104 | "tank_removed": tank_removed, 105 | "tilted_alert": tilted_alert, 106 | "filter_missing": filter_missing, 107 | "is_meter_binded": is_meter_binded, 108 | "humidity": humidity, 109 | "temperature": _temp_c, 110 | "temp": {"c": _temp_c, "f": _temp_f}, 111 | "water_level": water_level, 112 | "filter_run_time": filter_run_time, 113 | "target_humidity": target_humidity, 114 | } 115 | 116 | @update_after_operation 117 | async def turn_on(self) -> bool: 118 | """Turn device on.""" 119 | result = await self._send_command(COMMAND_TURN_ON) 120 | return self._check_command_result(result, 0, {1}) 121 | 122 | @update_after_operation 123 | async def turn_off(self) -> bool: 124 | """Turn device off.""" 125 | result = await self._send_command(COMMAND_TURN_OFF) 126 | return self._check_command_result(result, 0, {1}) 127 | 128 | @update_after_operation 129 | async def set_target_humidity(self, target_humidity: int) -> bool: 130 | """Set target humidity.""" 131 | self._validate_water_level() 132 | self._validate_mode_for_target_humidity() 133 | command = ( 134 | COMMAND_SET_MODE 135 | + MODES_COMMANDS[self.get_mode()] 136 | + f"{target_humidity:02x}" 137 | ) 138 | result = await self._send_command(command) 139 | return self._check_command_result(result, 0, {1}) 140 | 141 | @update_after_operation 142 | async def set_mode(self, mode: HumidifierMode) -> bool: 143 | """Set device mode.""" 144 | self._validate_water_level() 145 | self._validate_meter_binding(mode) 146 | 147 | if mode == HumidifierMode.DRYING_FILTER: 148 | command = COMMAND_SET_DRYING_FILTER 149 | else: 150 | command = COMMAND_SET_MODE + MODES_COMMANDS[mode] 151 | 152 | if mode in TARGET_HUMIDITY_MODES: 153 | target_humidity = self.get_target_humidity() 154 | if target_humidity is None: 155 | raise SwitchbotOperationError( 156 | "Target humidity must be set before switching to target humidity mode or sleep mode" 157 | ) 158 | command += f"{target_humidity:02x}" 159 | result = await self._send_command(command) 160 | return self._check_command_result(result, 0, {1}) 161 | 162 | def _validate_water_level(self) -> None: 163 | """Validate that the water level is not empty.""" 164 | if self.get_water_level() == HumidifierWaterLevel.EMPTY.name.lower(): 165 | raise SwitchbotOperationError( 166 | "Cannot perform operation when water tank is empty" 167 | ) 168 | 169 | def _validate_mode_for_target_humidity(self) -> None: 170 | """Validate that the current mode supports target humidity.""" 171 | if self.get_mode() not in TARGET_HUMIDITY_MODES: 172 | raise SwitchbotOperationError( 173 | "Target humidity can only be set in target humidity mode or sleep mode" 174 | ) 175 | 176 | def _validate_meter_binding(self, mode: HumidifierMode) -> None: 177 | """Validate that the meter is binded for specific modes.""" 178 | if not self.is_meter_binded() and mode in [ 179 | HumidifierMode.TARGET_HUMIDITY, 180 | HumidifierMode.AUTO, 181 | ]: 182 | raise SwitchbotOperationError( 183 | "Cannot set target humidity or auto mode when meter is not binded" 184 | ) 185 | 186 | @update_after_operation 187 | async def set_child_lock(self, enabled: bool) -> bool: 188 | """Set child lock.""" 189 | result = await self._send_command( 190 | COMMAND_CHILD_LOCK_ON if enabled else COMMAND_CHILD_LOCK_OFF 191 | ) 192 | return self._check_command_result(result, 0, {1}) 193 | 194 | def is_on(self) -> bool | None: 195 | """Return state from cache.""" 196 | return self._get_adv_value("isOn") 197 | 198 | def get_mode(self) -> HumidifierMode | None: 199 | """Return state from cache.""" 200 | return self._get_adv_value("mode") 201 | 202 | def is_child_lock_enabled(self) -> bool | None: 203 | """Return state from cache.""" 204 | return self._get_adv_value("child_lock") 205 | 206 | def is_over_humidify_protection_enabled(self) -> bool | None: 207 | """Return state from cache.""" 208 | return self._get_adv_value("over_humidify_protection") 209 | 210 | def is_tank_removed(self) -> bool | None: 211 | """Return state from cache.""" 212 | return self._get_adv_value("tank_removed") 213 | 214 | def is_filter_missing(self) -> bool | None: 215 | """Return state from cache.""" 216 | return self._get_adv_value("filter_missing") 217 | 218 | def is_filter_alert_on(self) -> bool | None: 219 | """Return state from cache.""" 220 | return self._get_adv_value("filter_alert") 221 | 222 | def is_tilted_alert_on(self) -> bool | None: 223 | """Return state from cache.""" 224 | return self._get_adv_value("tilted_alert") 225 | 226 | def get_water_level(self) -> HumidifierWaterLevel | None: 227 | """Return state from cache.""" 228 | return self._get_adv_value("water_level") 229 | 230 | def get_filter_run_time(self) -> int | None: 231 | """Return state from cache.""" 232 | return self._get_adv_value("filter_run_time") 233 | 234 | def get_target_humidity(self) -> int | None: 235 | """Return state from cache.""" 236 | return self._get_adv_value("target_humidity") 237 | 238 | def get_humidity(self) -> int | None: 239 | """Return state from cache.""" 240 | return self._get_adv_value("humidity") 241 | 242 | def get_temperature(self) -> float | None: 243 | """Return state from cache.""" 244 | return self._get_adv_value("temperature") 245 | 246 | def get_action(self) -> int: 247 | """Return current action from cache.""" 248 | if not self.is_on(): 249 | return HumidifierAction.OFF 250 | if self.get_mode() != HumidifierMode.DRYING_FILTER: 251 | return HumidifierAction.HUMIDIFYING 252 | return HumidifierAction.DRYING 253 | 254 | def is_meter_binded(self) -> bool | None: 255 | """Return meter bind state from cache.""" 256 | return self._get_adv_value("is_meter_binded") 257 | -------------------------------------------------------------------------------- /switchbot/devices/fan.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from ..const.fan import FanMode 9 | from .device import ( 10 | DEVICE_GET_BASIC_SETTINGS_KEY, 11 | SwitchbotSequenceDevice, 12 | update_after_operation, 13 | ) 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | COMMAND_HEAD = "570f41" 19 | COMMAND_TURN_ON = f"{COMMAND_HEAD}0101" 20 | COMMAND_TURN_OFF = f"{COMMAND_HEAD}0102" 21 | COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff" 22 | COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff" 23 | COMMAND_SET_MODE = { 24 | FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff", 25 | FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff", 26 | FanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103", 27 | FanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104", 28 | } 29 | COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302" # +speed 30 | COMMAND_GET_BASIC_INFO = "570f428102" 31 | 32 | 33 | class SwitchbotFan(SwitchbotSequenceDevice): 34 | """Representation of a Switchbot Circulator Fan.""" 35 | 36 | def __init__(self, device, password=None, interface=0, **kwargs): 37 | super().__init__(device, password, interface, **kwargs) 38 | 39 | async def get_basic_info(self) -> dict[str, Any] | None: 40 | """Get device basic settings.""" 41 | if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)): 42 | return None 43 | if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)): 44 | return None 45 | 46 | _LOGGER.debug("data: %s", _data) 47 | battery = _data[2] & 0b01111111 48 | isOn = bool(_data[3] & 0b10000000) 49 | oscillating = bool(_data[3] & 0b01100000) 50 | _mode = _data[8] & 0b00000111 51 | mode = FanMode(_mode).name.lower() if 1 <= _mode <= 4 else None 52 | speed = _data[9] 53 | firmware = _data1[2] / 10.0 54 | 55 | return { 56 | "battery": battery, 57 | "isOn": isOn, 58 | "oscillating": oscillating, 59 | "mode": mode, 60 | "speed": speed, 61 | "firmware": firmware, 62 | } 63 | 64 | async def _get_basic_info(self, cmd: str) -> bytes | None: 65 | """Return basic info of device.""" 66 | _data = await self._send_command(key=cmd, retry=self._retry_count) 67 | 68 | if _data in (b"\x07", b"\x00"): 69 | _LOGGER.error("Unsuccessful, please try again") 70 | return None 71 | 72 | return _data 73 | 74 | @update_after_operation 75 | async def set_preset_mode(self, preset_mode: str) -> bool: 76 | """Send command to set fan preset_mode.""" 77 | return await self._send_command(COMMAND_SET_MODE[preset_mode]) 78 | 79 | @update_after_operation 80 | async def set_percentage(self, percentage: int) -> bool: 81 | """Send command to set fan percentage.""" 82 | return await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}") 83 | 84 | @update_after_operation 85 | async def set_oscillation(self, oscillating: bool) -> bool: 86 | """Send command to set fan oscillation""" 87 | if oscillating: 88 | return await self._send_command(COMMAND_START_OSCILLATION) 89 | return await self._send_command(COMMAND_STOP_OSCILLATION) 90 | 91 | @update_after_operation 92 | async def turn_on(self) -> bool: 93 | """Turn on the fan.""" 94 | return await self._send_command(COMMAND_TURN_ON) 95 | 96 | @update_after_operation 97 | async def turn_off(self) -> bool: 98 | """Turn off the fan.""" 99 | return await self._send_command(COMMAND_TURN_OFF) 100 | 101 | def get_current_percentage(self) -> Any: 102 | """Return cached percentage.""" 103 | return self._get_adv_value("speed") 104 | 105 | def is_on(self) -> bool | None: 106 | """Return fan state from cache.""" 107 | return self._get_adv_value("isOn") 108 | 109 | def get_oscillating_state(self) -> Any: 110 | """Return cached oscillating.""" 111 | return self._get_adv_value("oscillating") 112 | 113 | def get_current_mode(self) -> Any: 114 | """Return cached mode.""" 115 | return self._get_adv_value("mode") 116 | -------------------------------------------------------------------------------- /switchbot/devices/humidifier.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import time 6 | 7 | from .device import REQ_HEADER, SwitchbotDevice 8 | 9 | HUMIDIFIER_COMMAND_HEADER = "4381" 10 | HUMIDIFIER_REQUEST = f"{REQ_HEADER}4481" 11 | HUMIDIFIER_COMMAND = f"{REQ_HEADER}{HUMIDIFIER_COMMAND_HEADER}" 12 | HUMIDIFIER_OFF_KEY = f"{HUMIDIFIER_COMMAND}010080FFFFFFFF" 13 | HUMIDIFIER_ON_KEY = f"{HUMIDIFIER_COMMAND}010180FFFFFFFF" 14 | ## 15 | # OFF 570F 4381 0100 80FF FFFF FF 16 | # ON 570F 4381 0101 80FF FFFF FF 17 | # AUTO 570F 4381 0101 80FF FFFF FF 18 | # 1. 570F 4381 0101 22FF FFFF FF 19 | # 2. 570F 4381 0101 43FF FFFF FF 20 | # 3 . 570F 4381 0101 64FF FFFF FF 21 | 22 | MANUAL_BUTTON_PRESSES_TO_LEVEL = { 23 | 101: 33, 24 | 102: 66, 25 | 103: 100, 26 | } 27 | 28 | 29 | class SwitchbotHumidifier(SwitchbotDevice): 30 | """Representation of a Switchbot humidifier.""" 31 | 32 | async def update(self, interface: int | None = None) -> None: 33 | """Update state of device.""" 34 | # No battery here 35 | self._last_full_update = time.monotonic() 36 | 37 | def _generate_command( 38 | self, on: bool | None = None, level: int | None = None 39 | ) -> str: 40 | """Generate command.""" 41 | if level is None: 42 | level = self.get_target_humidity() or 128 43 | if on is None: 44 | on = self.is_on() 45 | on_hex = "01" if on else "00" 46 | return f"{HUMIDIFIER_COMMAND}01{on_hex}{level:02X}FFFFFFFF" 47 | 48 | async def _async_set_state(self, state: bool) -> bool: 49 | level = self.get_target_humidity() or 128 50 | result = await self._send_command(self._generate_command(on=state, level=level)) 51 | ret = self._check_command_result(result, 0, {0x01}) 52 | self._override_state({"isOn": state, "level": level}) 53 | self._fire_callbacks() 54 | return ret 55 | 56 | async def turn_on(self) -> bool: 57 | """Turn device on.""" 58 | await self._async_set_state(True) 59 | 60 | async def turn_off(self) -> bool: 61 | """Turn device off.""" 62 | await self._async_set_state(False) 63 | 64 | async def set_level(self, level: int) -> bool: 65 | """Set level.""" 66 | assert 1 <= level <= 100, "Level must be between 1 and 100" 67 | await self._set_level(level) 68 | 69 | async def _set_level(self, level: int) -> bool: 70 | """Set level.""" 71 | result = await self._send_command(self._generate_command(level=level)) 72 | ret = self._check_command_result(result, 0, {0x01}) 73 | self._override_state({"level": level}) 74 | self._fire_callbacks() 75 | return ret 76 | 77 | async def async_set_auto(self) -> bool: 78 | """Set auto mode.""" 79 | await self._set_level(128) 80 | 81 | async def async_set_manual(self) -> bool: 82 | """Set manual mode.""" 83 | await self._set_level(50) 84 | 85 | def is_auto(self) -> bool: 86 | """Return auto state from cache.""" 87 | return self.get_level() in (228, 128) 88 | 89 | def get_level(self) -> int | None: 90 | """Return level state from cache.""" 91 | return self._get_adv_value("level") 92 | 93 | def is_on(self) -> bool | None: 94 | """Return switch state from cache.""" 95 | return self._get_adv_value("isOn") 96 | 97 | def get_target_humidity(self) -> int | None: 98 | """Return target humidity from cache.""" 99 | level = self.get_level() 100 | if self.is_auto(): 101 | return None 102 | return MANUAL_BUTTON_PRESSES_TO_LEVEL.get(level, level) 103 | 104 | def poll_needed(self, last_poll_time: float | None) -> bool: 105 | """Return if device needs polling.""" 106 | return False 107 | -------------------------------------------------------------------------------- /switchbot/devices/keypad.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /switchbot/devices/light_strip.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from .base_light import SwitchbotSequenceBaseLight 6 | from .device import REQ_HEADER, ColorMode 7 | 8 | STRIP_COMMMAND_HEADER = "4901" 9 | STRIP_REQUEST = f"{REQ_HEADER}4A01" 10 | 11 | STRIP_COMMAND = f"{REQ_HEADER}{STRIP_COMMMAND_HEADER}" 12 | # Strip keys 13 | STRIP_ON_KEY = f"{STRIP_COMMAND}01" 14 | STRIP_OFF_KEY = f"{STRIP_COMMAND}02" 15 | RGB_BRIGHTNESS_KEY = f"{STRIP_COMMAND}12" 16 | BRIGHTNESS_KEY = f"{STRIP_COMMAND}14" 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class SwitchbotLightStrip(SwitchbotSequenceBaseLight): 22 | """Representation of a Switchbot light strip.""" 23 | 24 | @property 25 | def color_modes(self) -> set[ColorMode]: 26 | """Return the supported color modes.""" 27 | return {ColorMode.RGB} 28 | 29 | async def update(self) -> None: 30 | """Update state of device.""" 31 | result = await self._send_command(STRIP_REQUEST) 32 | self._update_state(result) 33 | await super().update() 34 | 35 | async def turn_on(self) -> bool: 36 | """Turn device on.""" 37 | result = await self._send_command(STRIP_ON_KEY) 38 | self._update_state(result) 39 | return self._check_command_result(result, 1, {0x80}) 40 | 41 | async def turn_off(self) -> bool: 42 | """Turn device off.""" 43 | result = await self._send_command(STRIP_OFF_KEY) 44 | self._update_state(result) 45 | return self._check_command_result(result, 1, {0x00}) 46 | 47 | async def set_brightness(self, brightness: int) -> bool: 48 | """Set brightness.""" 49 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 50 | result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}") 51 | self._update_state(result) 52 | return self._check_command_result(result, 1, {0x80}) 53 | 54 | async def set_color_temp(self, brightness: int, color_temp: int) -> bool: 55 | """Set color temp.""" 56 | # not supported on this device 57 | 58 | async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: 59 | """Set rgb.""" 60 | assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" 61 | assert 0 <= r <= 255, "r must be between 0 and 255" 62 | assert 0 <= g <= 255, "g must be between 0 and 255" 63 | assert 0 <= b <= 255, "b must be between 0 and 255" 64 | result = await self._send_command( 65 | f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}" 66 | ) 67 | self._update_state(result) 68 | return self._check_command_result(result, 1, {0x80}) 69 | 70 | def _update_state(self, result: bytes | None) -> None: 71 | """Update device state.""" 72 | if not result or len(result) < 10: 73 | return 74 | self._state["r"] = result[3] 75 | self._state["g"] = result[4] 76 | self._state["b"] = result[5] 77 | self._override_state( 78 | { 79 | "isOn": result[1] == 0x80, 80 | "color_mode": result[10], 81 | } 82 | ) 83 | _LOGGER.debug("%s: update state: %s = %s", self.name, result.hex(), self._state) 84 | self._fire_callbacks() 85 | -------------------------------------------------------------------------------- /switchbot/devices/lock.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot Lock.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import time 7 | from typing import Any 8 | 9 | from bleak.backends.device import BLEDevice 10 | 11 | from ..const import SwitchbotModel 12 | from ..const.lock import LockStatus 13 | from .device import SwitchbotEncryptedDevice, SwitchbotSequenceDevice 14 | 15 | COMMAND_HEADER = "57" 16 | COMMAND_LOCK_INFO = { 17 | SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4f8101", 18 | SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4f8101", 19 | SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8102", 20 | SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4f8102", 21 | } 22 | COMMAND_UNLOCK = { 23 | SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011080", 24 | SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011080", 25 | SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080", 26 | SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000080", 27 | } 28 | COMMAND_UNLOCK_WITHOUT_UNLATCH = { 29 | SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e010110a0", 30 | SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e010110a0", 31 | SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0", 32 | SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e01010000a0", 33 | } 34 | COMMAND_LOCK = { 35 | SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011000", 36 | SwitchbotModel.LOCK_LITE: f"{COMMAND_HEADER}0f4e01011000", 37 | SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000", 38 | SwitchbotModel.LOCK_ULTRA: f"{COMMAND_HEADER}0f4e0101000000", 39 | } 40 | COMMAND_ENABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e01001e00008101" 41 | COMMAND_DISABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e00" 42 | 43 | MOVING_STATUSES = {LockStatus.LOCKING, LockStatus.UNLOCKING} 44 | BLOCKED_STATUSES = {LockStatus.LOCKING_STOP, LockStatus.UNLOCKING_STOP} 45 | REST_STATUSES = {LockStatus.LOCKED, LockStatus.UNLOCKED, LockStatus.NOT_FULLY_LOCKED} 46 | 47 | _LOGGER = logging.getLogger(__name__) 48 | 49 | 50 | COMMAND_RESULT_EXPECTED_VALUES = {1, 6} 51 | # The return value of the command is 1 when the command is successful. 52 | # The return value of the command is 6 when the command is successful but the battery is low. 53 | 54 | 55 | class SwitchbotLock(SwitchbotSequenceDevice, SwitchbotEncryptedDevice): 56 | """Representation of a Switchbot Lock.""" 57 | 58 | def __init__( 59 | self, 60 | device: BLEDevice, 61 | key_id: str, 62 | encryption_key: str, 63 | interface: int = 0, 64 | model: SwitchbotModel = SwitchbotModel.LOCK, 65 | **kwargs: Any, 66 | ) -> None: 67 | if model not in ( 68 | SwitchbotModel.LOCK, 69 | SwitchbotModel.LOCK_PRO, 70 | SwitchbotModel.LOCK_LITE, 71 | SwitchbotModel.LOCK_ULTRA, 72 | ): 73 | raise ValueError("initializing SwitchbotLock with a non-lock model") 74 | self._notifications_enabled: bool = False 75 | super().__init__(device, key_id, encryption_key, model, interface, **kwargs) 76 | 77 | @classmethod 78 | async def verify_encryption_key( 79 | cls, 80 | device: BLEDevice, 81 | key_id: str, 82 | encryption_key: str, 83 | model: SwitchbotModel = SwitchbotModel.LOCK, 84 | **kwargs: Any, 85 | ) -> bool: 86 | return await super().verify_encryption_key( 87 | device, key_id, encryption_key, model, **kwargs 88 | ) 89 | 90 | async def lock(self) -> bool: 91 | """Send lock command.""" 92 | return await self._lock_unlock( 93 | COMMAND_LOCK[self._model], {LockStatus.LOCKED, LockStatus.LOCKING} 94 | ) 95 | 96 | async def unlock(self) -> bool: 97 | """Send unlock command. If unlatch feature is enabled in EU firmware, also unlatches door""" 98 | return await self._lock_unlock( 99 | COMMAND_UNLOCK[self._model], {LockStatus.UNLOCKED, LockStatus.UNLOCKING} 100 | ) 101 | 102 | async def unlock_without_unlatch(self) -> bool: 103 | """Send unlock command. This command will not unlatch the door.""" 104 | return await self._lock_unlock( 105 | COMMAND_UNLOCK_WITHOUT_UNLATCH[self._model], 106 | {LockStatus.UNLOCKED, LockStatus.UNLOCKING, LockStatus.NOT_FULLY_LOCKED}, 107 | ) 108 | 109 | def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]: 110 | """Parse basic data from lock.""" 111 | return { 112 | "battery": basic_data[1], 113 | "firmware": basic_data[2] / 10.0, 114 | } 115 | 116 | async def _lock_unlock( 117 | self, command: str, ignore_statuses: set[LockStatus] 118 | ) -> bool: 119 | status = self.get_lock_status() 120 | if status is None: 121 | await self.update() 122 | status = self.get_lock_status() 123 | if status in ignore_statuses: 124 | return True 125 | 126 | await self._enable_notifications() 127 | result = await self._send_command(command) 128 | status = self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES) 129 | 130 | # Also update the battery and firmware version 131 | if basic_data := await self._get_basic_info(): 132 | self._last_full_update = time.monotonic() 133 | if len(basic_data) >= 3: 134 | self._update_parsed_data(self._parse_basic_data(basic_data)) 135 | else: 136 | _LOGGER.warning("Invalid basic data received: %s", basic_data) 137 | self._fire_callbacks() 138 | 139 | return status 140 | 141 | async def get_basic_info(self) -> dict[str, Any] | None: 142 | """Get device basic status.""" 143 | lock_raw_data = await self._get_lock_info() 144 | if not lock_raw_data: 145 | return None 146 | 147 | basic_data = await self._get_basic_info() 148 | if not basic_data: 149 | return None 150 | 151 | return self._parse_lock_data(lock_raw_data[1:]) | self._parse_basic_data( 152 | basic_data 153 | ) 154 | 155 | def is_calibrated(self) -> Any: 156 | """Return True if lock is calibrated.""" 157 | return self._get_adv_value("calibration") 158 | 159 | def get_lock_status(self) -> LockStatus: 160 | """Return lock status.""" 161 | return self._get_adv_value("status") 162 | 163 | def is_door_open(self) -> bool: 164 | """Return True if door is open.""" 165 | return self._get_adv_value("door_open") 166 | 167 | def is_unclosed_alarm_on(self) -> bool: 168 | """Return True if unclosed door alarm is on.""" 169 | return self._get_adv_value("unclosed_alarm") 170 | 171 | def is_unlocked_alarm_on(self) -> bool: 172 | """Return True if lock unlocked alarm is on.""" 173 | return self._get_adv_value("unlocked_alarm") 174 | 175 | def is_auto_lock_paused(self) -> bool: 176 | """Return True if auto lock is paused.""" 177 | return self._get_adv_value("auto_lock_paused") 178 | 179 | def is_night_latch_enabled(self) -> bool: 180 | """Return True if Night Latch is enabled on EU firmware.""" 181 | return self._get_adv_value("night_latch") 182 | 183 | async def _get_lock_info(self) -> bytes | None: 184 | """Return lock info of device.""" 185 | _data = await self._send_command( 186 | key=COMMAND_LOCK_INFO[self._model], retry=self._retry_count 187 | ) 188 | 189 | if not self._check_command_result(_data, 0, COMMAND_RESULT_EXPECTED_VALUES): 190 | _LOGGER.error("Unsuccessful, please try again") 191 | return None 192 | 193 | return _data 194 | 195 | async def _enable_notifications(self) -> bool: 196 | if self._notifications_enabled: 197 | return True 198 | result = await self._send_command(COMMAND_ENABLE_NOTIFICATIONS) 199 | if self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES): 200 | self._notifications_enabled = True 201 | return self._notifications_enabled 202 | 203 | async def _disable_notifications(self) -> bool: 204 | if not self._notifications_enabled: 205 | return True 206 | result = await self._send_command(COMMAND_DISABLE_NOTIFICATIONS) 207 | if self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES): 208 | self._notifications_enabled = False 209 | return not self._notifications_enabled 210 | 211 | def _notification_handler(self, _sender: int, data: bytearray) -> None: 212 | if self._notifications_enabled and self._check_command_result(data, 0, {0xF}): 213 | self._update_lock_status(data) 214 | else: 215 | super()._notification_handler(_sender, data) 216 | 217 | def _update_lock_status(self, data: bytearray) -> None: 218 | lock_data = self._parse_lock_data(self._decrypt(data[4:])) 219 | if self._update_parsed_data(lock_data): 220 | # We leave notifications enabled in case 221 | # the lock is operated manually before we 222 | # disconnect. 223 | self._reset_disconnect_timer() 224 | self._fire_callbacks() 225 | 226 | @staticmethod 227 | def _parse_lock_data(data: bytes) -> dict[str, Any]: 228 | return { 229 | "calibration": bool(data[0] & 0b10000000), 230 | "status": LockStatus((data[0] & 0b01110000) >> 4), 231 | "door_open": bool(data[0] & 0b00000100), 232 | "unclosed_alarm": bool(data[1] & 0b00100000), 233 | "unlocked_alarm": bool(data[1] & 0b00010000), 234 | } 235 | -------------------------------------------------------------------------------- /switchbot/devices/meter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /switchbot/devices/motion.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /switchbot/devices/plug.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import time 6 | 7 | from .device import REQ_HEADER, SwitchbotDeviceOverrideStateDuringConnection 8 | 9 | # Plug Mini keys 10 | PLUG_ON_KEY = f"{REQ_HEADER}50010180" 11 | PLUG_OFF_KEY = f"{REQ_HEADER}50010100" 12 | 13 | 14 | class SwitchbotPlugMini(SwitchbotDeviceOverrideStateDuringConnection): 15 | """Representation of a Switchbot plug mini.""" 16 | 17 | async def update(self, interface: int | None = None) -> None: 18 | """Update state of device.""" 19 | # No battery here 20 | self._last_full_update = time.monotonic() 21 | 22 | async def turn_on(self) -> bool: 23 | """Turn device on.""" 24 | result = await self._send_command(PLUG_ON_KEY) 25 | ret = self._check_command_result(result, 1, {0x80}) 26 | self._override_state({"isOn": True}) 27 | self._fire_callbacks() 28 | return ret 29 | 30 | async def turn_off(self) -> bool: 31 | """Turn device off.""" 32 | result = await self._send_command(PLUG_OFF_KEY) 33 | ret = self._check_command_result(result, 1, {0x80}) 34 | self._override_state({"isOn": False}) 35 | self._fire_callbacks() 36 | return ret 37 | 38 | def is_on(self) -> bool | None: 39 | """Return switch state from cache.""" 40 | return self._get_adv_value("isOn") 41 | 42 | def poll_needed(self, last_poll_time: float | None) -> bool: 43 | """Return if device needs polling.""" 44 | return False 45 | -------------------------------------------------------------------------------- /switchbot/devices/relay_switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import Any 4 | 5 | from bleak.backends.device import BLEDevice 6 | 7 | from ..const import SwitchbotModel 8 | from ..helpers import parse_power_data, parse_uint24_be 9 | from ..models import SwitchBotAdvertisement 10 | from .device import ( 11 | SwitchbotEncryptedDevice, 12 | SwitchbotSequenceDevice, 13 | update_after_operation, 14 | ) 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | # Bit masks for status parsing 19 | SWITCH1_ON_MASK = 0b10000000 20 | SWITCH2_ON_MASK = 0b01000000 21 | DOOR_OPEN_MASK = 0b00100000 22 | 23 | COMMAND_HEADER = "57" 24 | COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f70010000" 25 | COMMAND_TURN_ON = f"{COMMAND_HEADER}0f70010100" 26 | COMMAND_TOGGLE = f"{COMMAND_HEADER}0f70010200" 27 | COMMAND_GET_VOLTAGE_AND_CURRENT = f"{COMMAND_HEADER}0f7106000000" 28 | 29 | COMMAND_GET_BASIC_INFO = f"{COMMAND_HEADER}0f7181" 30 | COMMAND_GET_CHANNEL1_INFO = f"{COMMAND_HEADER}0f710600{{}}{{}}" 31 | COMMAND_GET_CHANNEL2_INFO = f"{COMMAND_HEADER}0f710601{{}}{{}}" 32 | 33 | 34 | MULTI_CHANNEL_COMMANDS_TURN_ON = { 35 | SwitchbotModel.RELAY_SWITCH_2PM: { 36 | 1: "570f70010d00", 37 | 2: "570f70010700", 38 | } 39 | } 40 | MULTI_CHANNEL_COMMANDS_TURN_OFF = { 41 | SwitchbotModel.RELAY_SWITCH_2PM: { 42 | 1: "570f70010c00", 43 | 2: "570f70010300", 44 | } 45 | } 46 | MULTI_CHANNEL_COMMANDS_TOGGLE = { 47 | SwitchbotModel.RELAY_SWITCH_2PM: { 48 | 1: "570f70010e00", 49 | 2: "570f70010b00", 50 | } 51 | } 52 | MULTI_CHANNEL_COMMANDS_GET_VOLTAGE_AND_CURRENT = { 53 | SwitchbotModel.RELAY_SWITCH_2PM: { 54 | 1: COMMAND_GET_CHANNEL1_INFO, 55 | 2: COMMAND_GET_CHANNEL2_INFO, 56 | } 57 | } 58 | 59 | 60 | class SwitchbotRelaySwitch(SwitchbotSequenceDevice, SwitchbotEncryptedDevice): 61 | """Representation of a Switchbot relay switch 1pm.""" 62 | 63 | def __init__( 64 | self, 65 | device: BLEDevice, 66 | key_id: str, 67 | encryption_key: str, 68 | interface: int = 0, 69 | model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM, 70 | **kwargs: Any, 71 | ) -> None: 72 | super().__init__(device, key_id, encryption_key, model, interface, **kwargs) 73 | 74 | @classmethod 75 | async def verify_encryption_key( 76 | cls, 77 | device: BLEDevice, 78 | key_id: str, 79 | encryption_key: str, 80 | model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM, 81 | **kwargs: Any, 82 | ) -> bool: 83 | return await super().verify_encryption_key( 84 | device, key_id, encryption_key, model, **kwargs 85 | ) 86 | 87 | def _reset_power_data(self, data: dict[str, Any]) -> None: 88 | """Reset power-related data to 0.""" 89 | for key in ["power", "current", "voltage"]: 90 | data[key] = 0 91 | 92 | def _parse_common_data(self, raw_data: bytes) -> dict[str, Any]: 93 | """Parse common data from raw bytes.""" 94 | return { 95 | "sequence_number": raw_data[1], 96 | "isOn": bool(raw_data[2] & SWITCH1_ON_MASK), 97 | "firmware": raw_data[16] / 10.0, 98 | "channel2_isOn": bool(raw_data[2] & SWITCH2_ON_MASK), 99 | } 100 | 101 | def _parse_user_data(self, raw_data: bytes) -> dict[str, Any]: 102 | """Parse user-specific data from raw bytes.""" 103 | _energy = parse_uint24_be(raw_data, 1) / 60000 104 | _energy_usage_yesterday = parse_uint24_be(raw_data, 4) / 60000 105 | _use_time = parse_power_data(raw_data, 7, 60.0) 106 | _voltage = parse_power_data(raw_data, 9, 10.0) 107 | _current = parse_power_data(raw_data, 11, 1000.0) 108 | _power = parse_power_data(raw_data, 13, 10.0) 109 | 110 | return { 111 | "energy": 0.01 if 0 < _energy <= 0.01 else round(_energy, 2), 112 | "energy usage yesterday": 0.01 113 | if 0 < _energy_usage_yesterday <= 0.01 114 | else round(_energy_usage_yesterday, 2), 115 | "use_time": round(_use_time, 1), 116 | "voltage": 0.1 if 0 < _voltage <= 0.1 else round(_voltage), 117 | "current": 0.1 if 0 < _current <= 0.1 else round(_current, 1), 118 | "power": 0.1 if 0 < _power <= 0.1 else round(_power, 1), 119 | } 120 | 121 | def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: 122 | """Update device data from advertisement.""" 123 | adv_data = advertisement.data["data"] 124 | channel = self._channel if hasattr(self, "_channel") else None 125 | 126 | if self._model in ( 127 | SwitchbotModel.RELAY_SWITCH_1PM, 128 | SwitchbotModel.RELAY_SWITCH_2PM, 129 | ): 130 | if channel is None: 131 | adv_data["voltage"] = self._get_adv_value("voltage") or 0 132 | adv_data["current"] = self._get_adv_value("current") or 0 133 | adv_data["power"] = self._get_adv_value("power") or 0 134 | adv_data["energy"] = self._get_adv_value("energy") or 0 135 | else: 136 | for i in range(1, channel + 1): 137 | adv_data[i] = adv_data.get(i, {}) 138 | adv_data[i]["voltage"] = self._get_adv_value("voltage", i) or 0 139 | adv_data[i]["current"] = self._get_adv_value("current", i) or 0 140 | adv_data[i]["power"] = self._get_adv_value("power", i) or 0 141 | adv_data[i]["energy"] = self._get_adv_value("energy", i) or 0 142 | super().update_from_advertisement(advertisement) 143 | 144 | def get_current_time_and_start_time(self) -> int: 145 | """Get current time in seconds since epoch.""" 146 | current_time = int(time.time()) 147 | current_time_hex = f"{current_time:08x}" 148 | current_day_start_time = int(current_time / 86400) * 86400 149 | current_day_start_time_hex = f"{current_day_start_time:08x}" 150 | 151 | return current_time_hex, current_day_start_time_hex 152 | 153 | async def get_basic_info(self) -> dict[str, Any] | None: 154 | """Get device basic settings.""" 155 | current_time_hex, current_day_start_time_hex = ( 156 | self.get_current_time_and_start_time() 157 | ) 158 | 159 | if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)): 160 | return None 161 | if not ( 162 | _channel1_data := await self._get_basic_info( 163 | COMMAND_GET_CHANNEL1_INFO.format( 164 | current_time_hex, current_day_start_time_hex 165 | ) 166 | ) 167 | ): 168 | return None 169 | 170 | _LOGGER.debug( 171 | "on-off hex: %s, channel1_hex_data: %s", _data.hex(), _channel1_data.hex() 172 | ) 173 | 174 | common_data = self._parse_common_data(_data) 175 | user_data = self._parse_user_data(_channel1_data) 176 | 177 | if self._model in ( 178 | SwitchbotModel.RELAY_SWITCH_1, 179 | SwitchbotModel.GARAGE_DOOR_OPENER, 180 | ): 181 | for key in ["voltage", "current", "power", "energy"]: 182 | user_data.pop(key, None) 183 | 184 | if not common_data["isOn"]: 185 | self._reset_power_data(user_data) 186 | 187 | garage_door_opener_data = {"door_open": not bool(_data[2] & DOOR_OPEN_MASK)} 188 | 189 | _LOGGER.debug("common_data: %s, user_data: %s", common_data, user_data) 190 | 191 | if self._model == SwitchbotModel.GARAGE_DOOR_OPENER: 192 | return common_data | garage_door_opener_data 193 | return common_data | user_data 194 | 195 | @update_after_operation 196 | async def turn_on(self) -> bool: 197 | """Turn device on.""" 198 | result = await self._send_command(COMMAND_TURN_ON) 199 | return self._check_command_result(result, 0, {1}) 200 | 201 | @update_after_operation 202 | async def turn_off(self) -> bool: 203 | """Turn device off.""" 204 | result = await self._send_command(COMMAND_TURN_OFF) 205 | return self._check_command_result(result, 0, {1}) 206 | 207 | @update_after_operation 208 | async def async_toggle(self, **kwargs) -> bool: 209 | """Toggle device.""" 210 | result = await self._send_command(COMMAND_TOGGLE) 211 | return self._check_command_result(result, 0, {1}) 212 | 213 | def is_on(self) -> bool | None: 214 | """Return switch state from cache.""" 215 | return self._get_adv_value("isOn") 216 | 217 | 218 | class SwitchbotRelaySwitch2PM(SwitchbotRelaySwitch): 219 | """Representation of a Switchbot relay switch 2pm.""" 220 | 221 | def __init__( 222 | self, 223 | device: BLEDevice, 224 | key_id: str, 225 | encryption_key: str, 226 | interface: int = 0, 227 | model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_2PM, 228 | **kwargs: Any, 229 | ) -> None: 230 | super().__init__(device, key_id, encryption_key, interface, model, **kwargs) 231 | self._channel = 2 232 | 233 | @property 234 | def channel(self) -> int: 235 | return self._channel 236 | 237 | def get_parsed_data(self, channel: int | None = None) -> dict[str, Any]: 238 | """Return parsed device data, optionally for a specific channel.""" 239 | data = self.data.get("data") or {} 240 | return data.get(channel, {}) 241 | 242 | async def get_basic_info(self): 243 | current_time_hex, current_day_start_time_hex = ( 244 | self.get_current_time_and_start_time() 245 | ) 246 | if not (common_data := await super().get_basic_info()): 247 | return None 248 | if not ( 249 | _channel2_data := await self._get_basic_info( 250 | COMMAND_GET_CHANNEL2_INFO.format( 251 | current_time_hex, current_day_start_time_hex 252 | ) 253 | ) 254 | ): 255 | return None 256 | 257 | _LOGGER.debug("channel2_hex_data: %s", _channel2_data.hex()) 258 | 259 | channel2_data = self._parse_user_data(_channel2_data) 260 | channel2_data["isOn"] = common_data["channel2_isOn"] 261 | 262 | if not channel2_data["isOn"]: 263 | self._reset_power_data(channel2_data) 264 | 265 | _LOGGER.debug( 266 | "channel1_data: %s, channel2_data: %s", common_data, channel2_data 267 | ) 268 | return {1: common_data, 2: channel2_data} 269 | 270 | @update_after_operation 271 | async def turn_on(self, channel: int) -> bool: 272 | """Turn device on.""" 273 | result = await self._send_command( 274 | MULTI_CHANNEL_COMMANDS_TURN_ON[self._model][channel] 275 | ) 276 | return self._check_command_result(result, 0, {1}) 277 | 278 | @update_after_operation 279 | async def turn_off(self, channel: int) -> bool: 280 | """Turn device off.""" 281 | result = await self._send_command( 282 | MULTI_CHANNEL_COMMANDS_TURN_OFF[self._model][channel] 283 | ) 284 | return self._check_command_result(result, 0, {1}) 285 | 286 | @update_after_operation 287 | async def async_toggle(self, channel: int) -> bool: 288 | """Toggle device.""" 289 | result = await self._send_command( 290 | MULTI_CHANNEL_COMMANDS_TOGGLE[self._model][channel] 291 | ) 292 | return self._check_command_result(result, 0, {1}) 293 | 294 | def is_on(self, channel: int) -> bool | None: 295 | """Return switch state from cache.""" 296 | return self._get_adv_value("isOn", channel) 297 | 298 | def switch_mode(self, channel: int) -> bool | None: 299 | """Return true or false from cache.""" 300 | return self._get_adv_value("switchMode", channel) 301 | -------------------------------------------------------------------------------- /switchbot/devices/roller_shade.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from ..models import SwitchBotAdvertisement 9 | from .base_cover import CONTROL_SOURCE, ROLLERSHADE_COMMAND, SwitchbotBaseCover 10 | from .device import REQ_HEADER, SwitchbotSequenceDevice, update_after_operation 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | OPEN_KEYS = [ 16 | f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0100", 17 | f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0000", 18 | ] 19 | CLOSE_KEYS = [ 20 | f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0164", 21 | f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0064", 22 | ] 23 | POSITION_KEYS = [ 24 | f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}01", 25 | f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}", 26 | ] # +actual_position 27 | STOP_KEYS = [f"{REQ_HEADER}{ROLLERSHADE_COMMAND}00{CONTROL_SOURCE}01"] 28 | 29 | 30 | class SwitchbotRollerShade(SwitchbotBaseCover, SwitchbotSequenceDevice): 31 | """Representation of a Switchbot Roller Shade.""" 32 | 33 | def __init__(self, *args: Any, **kwargs: Any) -> None: 34 | """Switchbot roller shade constructor.""" 35 | # The position of the roller shade is saved returned with 0 = open and 100 = closed. 36 | # the definition of position is the same as in Home Assistant. 37 | 38 | self._reverse: bool = kwargs.pop("reverse_mode", True) 39 | super().__init__(self._reverse, *args, **kwargs) 40 | 41 | def _set_parsed_data( 42 | self, advertisement: SwitchBotAdvertisement, data: dict[str, Any] 43 | ) -> None: 44 | """Set data.""" 45 | in_motion = data["inMotion"] 46 | previous_position = self._get_adv_value("position") 47 | new_position = data["position"] 48 | self._update_motion_direction(in_motion, previous_position, new_position) 49 | super()._set_parsed_data(advertisement, data) 50 | 51 | @update_after_operation 52 | async def open(self, mode: int = 0) -> bool: 53 | """Send open command. 0 - performance mode, 1 - unfelt mode.""" 54 | self._is_opening = True 55 | self._is_closing = False 56 | return await self._send_multiple_commands(OPEN_KEYS) 57 | 58 | @update_after_operation 59 | async def close(self, speed: int = 0) -> bool: 60 | """Send close command. 0 - performance mode, 1 - unfelt mode.""" 61 | self._is_closing = True 62 | self._is_opening = False 63 | return await self._send_multiple_commands(CLOSE_KEYS) 64 | 65 | @update_after_operation 66 | async def stop(self) -> bool: 67 | """Send stop command to device.""" 68 | self._is_opening = self._is_closing = False 69 | return await self._send_multiple_commands(STOP_KEYS) 70 | 71 | @update_after_operation 72 | async def set_position(self, position: int, mode: int = 0) -> bool: 73 | """Send position command (0-100) to device. 0 - performance mode, 1 - unfelt mode.""" 74 | position = (100 - position) if self._reverse else position 75 | self._update_motion_direction(True, self._get_adv_value("position"), position) 76 | return await self._send_multiple_commands( 77 | [ 78 | f"{POSITION_KEYS[0]}{position:02X}", 79 | f"{POSITION_KEYS[1]}{mode:02X}{position:02X}", 80 | ] 81 | ) 82 | 83 | def get_position(self) -> Any: 84 | """Return cached position (0-100) of Curtain.""" 85 | # To get actual position call update() first. 86 | return self._get_adv_value("position") 87 | 88 | async def get_basic_info(self) -> dict[str, Any] | None: 89 | """Get device basic settings.""" 90 | if not (_data := await self._get_basic_info()): 91 | return None 92 | 93 | _position = max(min(_data[5], 100), 0) 94 | _direction_adjusted_position = (100 - _position) if self._reverse else _position 95 | _previous_position = self._get_adv_value("position") 96 | _in_motion = bool(_data[4] & 0b00000011) 97 | self._update_motion_direction( 98 | _in_motion, _previous_position, _direction_adjusted_position 99 | ) 100 | 101 | return { 102 | "battery": _data[1], 103 | "firmware": _data[2] / 10.0, 104 | "chainLength": _data[3], 105 | "openDirection": ( 106 | "clockwise" if _data[4] & 0b10000000 == 128 else "anticlockwise" 107 | ), 108 | "fault": bool(_data[4] & 0b00010000), 109 | "solarPanel": bool(_data[4] & 0b00001000), 110 | "calibration": bool(_data[4] & 0b00000100), 111 | "calibrated": bool(_data[4] & 0b00000100), 112 | "inMotion": _in_motion, 113 | "position": _direction_adjusted_position, 114 | "timers": _data[6], 115 | } 116 | 117 | def _update_motion_direction( 118 | self, in_motion: bool, previous_position: int | None, new_position: int 119 | ) -> None: 120 | """Update opening/closing status based on movement.""" 121 | if previous_position is None: 122 | return 123 | if in_motion is False: 124 | self._is_closing = self._is_opening = False 125 | return 126 | 127 | if new_position != previous_position: 128 | self._is_opening = new_position > previous_position 129 | self._is_closing = new_position < previous_position 130 | -------------------------------------------------------------------------------- /switchbot/devices/vacuum.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from .device import SwitchbotSequenceDevice, update_after_operation 8 | 9 | COMMAND_CLEAN_UP = { 10 | 1: "570F5A00FFFF7001", 11 | 2: "5A400101010126", 12 | } 13 | COMMAND_RETURN_DOCK = { 14 | 1: "570F5A00FFFF7002", 15 | 2: "5A400101010225", 16 | } 17 | 18 | 19 | class SwitchbotVacuum(SwitchbotSequenceDevice): 20 | """Representation of a Switchbot Vacuum.""" 21 | 22 | def __init__(self, device, password=None, interface=0, **kwargs): 23 | super().__init__(device, password, interface, **kwargs) 24 | 25 | @update_after_operation 26 | async def clean_up(self, protocol_version: int) -> bool: 27 | """Send command to perform a spot clean-up.""" 28 | return await self._send_command(COMMAND_CLEAN_UP[protocol_version]) 29 | 30 | @update_after_operation 31 | async def return_to_dock(self, protocol_version: int) -> bool: 32 | """Send command to return the dock.""" 33 | return await self._send_command(COMMAND_RETURN_DOCK[protocol_version]) 34 | 35 | async def get_basic_info(self) -> dict[str, Any] | None: 36 | """Only support get the ble version through the command.""" 37 | if not (_data := await self._get_basic_info()): 38 | return None 39 | return { 40 | "firmware": _data[2], 41 | } 42 | 43 | def get_soc_version(self) -> str: 44 | """Return device soc version.""" 45 | return self._get_adv_value("soc_version") 46 | 47 | def get_last_step(self) -> int: 48 | """Return device last step after network configuration.""" 49 | return self._get_adv_value("step") 50 | 51 | def get_mqtt_connnect_status(self) -> bool: 52 | """Return device mqtt connect status.""" 53 | return self._get_adv_value("mqtt_connected") 54 | 55 | def get_battery(self) -> int: 56 | """Return device battery.""" 57 | return self._get_adv_value("battery") 58 | 59 | def get_work_status(self) -> int: 60 | """Return device work status.""" 61 | return self._get_adv_value("work_status") 62 | 63 | def get_dustbin_bound_status(self) -> bool: 64 | """Return the dustbin bound status""" 65 | return self._get_adv_value("dustbin_bound") 66 | 67 | def get_dustbin_connnected_status(self) -> bool: 68 | """Return the dustbin connected status""" 69 | return self._get_adv_value("dusbin_connected") 70 | 71 | def get_network_connected_status(self) -> bool: 72 | """Return the network connected status""" 73 | return self._get_adv_value("network_connected") 74 | -------------------------------------------------------------------------------- /switchbot/discovery.py: -------------------------------------------------------------------------------- 1 | """Discover switchbot devices.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | 8 | import bleak 9 | from bleak.backends.device import BLEDevice 10 | from bleak.backends.scanner import AdvertisementData 11 | 12 | from .adv_parser import parse_advertisement_data 13 | from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DEFAULT_SCAN_TIMEOUT 14 | from .models import SwitchBotAdvertisement 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | CONNECT_LOCK = asyncio.Lock() 18 | 19 | 20 | class GetSwitchbotDevices: 21 | """Scan for all Switchbot devices and return by type.""" 22 | 23 | def __init__(self, interface: int = 0) -> None: 24 | """Get switchbot devices class constructor.""" 25 | self._interface = f"hci{interface}" 26 | self._adv_data: dict[str, SwitchBotAdvertisement] = {} 27 | 28 | def detection_callback( 29 | self, 30 | device: BLEDevice, 31 | advertisement_data: AdvertisementData, 32 | ) -> None: 33 | """Callback for device detection.""" 34 | discovery = parse_advertisement_data(device, advertisement_data) 35 | if discovery: 36 | self._adv_data[discovery.address] = discovery 37 | 38 | async def discover( 39 | self, retry: int = DEFAULT_RETRY_COUNT, scan_timeout: int = DEFAULT_SCAN_TIMEOUT 40 | ) -> dict: 41 | """Find switchbot devices and their advertisement data.""" 42 | devices = None 43 | devices = bleak.BleakScanner( 44 | detection_callback=self.detection_callback, 45 | # TODO: Find new UUIDs to filter on. For example, see 46 | # https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/4ad138bb09f0fbbfa41b152ca327a78c1d0b6ba9/devicetypes/meter.md 47 | adapter=self._interface, 48 | ) 49 | 50 | async with CONNECT_LOCK: 51 | await devices.start() 52 | await asyncio.sleep(scan_timeout) 53 | await devices.stop() 54 | 55 | if devices is None: 56 | if retry < 1: 57 | _LOGGER.error( 58 | "Scanning for Switchbot devices failed. Stop trying", exc_info=True 59 | ) 60 | return self._adv_data 61 | 62 | _LOGGER.warning( 63 | "Error scanning for Switchbot devices. Retrying (remaining: %d)", 64 | retry, 65 | ) 66 | await asyncio.sleep(DEFAULT_RETRY_TIMEOUT) 67 | return await self.discover(retry - 1, scan_timeout) 68 | 69 | return self._adv_data 70 | 71 | async def _get_devices_by_model( 72 | self, 73 | model: str, 74 | ) -> dict: 75 | """Get switchbot devices by type.""" 76 | if not self._adv_data: 77 | await self.discover() 78 | 79 | return { 80 | address: adv 81 | for address, adv in self._adv_data.items() 82 | if adv.data.get("model") == model 83 | } 84 | 85 | async def get_blind_tilts(self) -> dict[str, SwitchBotAdvertisement]: 86 | """Return all WoBlindTilt/BlindTilts devices with services data.""" 87 | regular_blinds = await self._get_devices_by_model("x") 88 | pairing_blinds = await self._get_devices_by_model("X") 89 | return {**regular_blinds, **pairing_blinds} 90 | 91 | async def get_curtains(self) -> dict[str, SwitchBotAdvertisement]: 92 | """Return all WoCurtain/Curtains devices with services data.""" 93 | regular_curtains = await self._get_devices_by_model("c") 94 | pairing_curtains = await self._get_devices_by_model("C") 95 | regular_curtains3 = await self._get_devices_by_model("{") 96 | pairing_curtains3 = await self._get_devices_by_model("[") 97 | return { 98 | **regular_curtains, 99 | **pairing_curtains, 100 | **regular_curtains3, 101 | **pairing_curtains3, 102 | } 103 | 104 | async def get_bots(self) -> dict[str, SwitchBotAdvertisement]: 105 | """Return all WoHand/Bot devices with services data.""" 106 | return await self._get_devices_by_model("H") 107 | 108 | async def get_tempsensors(self) -> dict[str, SwitchBotAdvertisement]: 109 | """Return all WoSensorTH/Temp sensor devices with services data.""" 110 | base_meters = await self._get_devices_by_model("T") 111 | plus_meters = await self._get_devices_by_model("i") 112 | io_meters = await self._get_devices_by_model("w") 113 | hub2_meters = await self._get_devices_by_model("v") 114 | hubmini_matter_meters = await self._get_devices_by_model("%") 115 | return { 116 | **base_meters, 117 | **plus_meters, 118 | **io_meters, 119 | **hub2_meters, 120 | **hubmini_matter_meters, 121 | } 122 | 123 | async def get_contactsensors(self) -> dict[str, SwitchBotAdvertisement]: 124 | """Return all WoContact/Contact sensor devices with services data.""" 125 | return await self._get_devices_by_model("d") 126 | 127 | async def get_leakdetectors(self) -> dict[str, SwitchBotAdvertisement]: 128 | """Return all Leak Detectors with services data.""" 129 | return await self._get_devices_by_model("&") 130 | 131 | async def get_locks(self) -> dict[str, SwitchBotAdvertisement]: 132 | """Return all WoLock/Locks devices with services data.""" 133 | locks = await self._get_devices_by_model("o") 134 | lock_pros = await self._get_devices_by_model("$") 135 | return {**locks, **lock_pros} 136 | 137 | async def get_keypads(self) -> dict[str, SwitchBotAdvertisement]: 138 | """Return all WoKeypad/Keypad devices with services data.""" 139 | return await self._get_devices_by_model("y") 140 | 141 | async def get_humidifiers(self) -> dict[str, SwitchBotAdvertisement]: 142 | """Return all humidifier devices with services data.""" 143 | humidifiers = await self._get_devices_by_model("e") 144 | evaporative_humidifiers = await self._get_devices_by_model("#") 145 | return {**humidifiers, **evaporative_humidifiers} 146 | 147 | async def get_device_data( 148 | self, address: str 149 | ) -> dict[str, SwitchBotAdvertisement] | None: 150 | """Return data for specific device.""" 151 | if not self._adv_data: 152 | await self.discover() 153 | 154 | return { 155 | device: adv 156 | for device, adv in self._adv_data.items() 157 | # MacOS uses UUIDs instead of MAC addresses 158 | if adv.data.get("address") == address 159 | } 160 | -------------------------------------------------------------------------------- /switchbot/enum.py: -------------------------------------------------------------------------------- 1 | """Enum backports from standard lib.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | from typing import Any, Self 7 | 8 | 9 | class StrEnum(str, Enum): 10 | """Partial backport of Python 3.11's StrEnum for our basic use cases.""" 11 | 12 | def __new__(cls, value: str, *args: Any, **kwargs: Any) -> Self: 13 | """Create a new StrEnum instance.""" 14 | if not isinstance(value, str): 15 | raise TypeError(f"{value!r} is not a string") 16 | return super().__new__(cls, value, *args, **kwargs) 17 | 18 | def __str__(self) -> str: 19 | """Return self.value.""" 20 | return str(self.value) 21 | -------------------------------------------------------------------------------- /switchbot/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import struct 5 | from collections.abc import Coroutine 6 | from typing import Any, TypeVar 7 | 8 | _R = TypeVar("_R") 9 | 10 | _BACKGROUND_TASKS: set[asyncio.Task[Any]] = set() 11 | 12 | # Pre-compiled struct unpack methods for better performance 13 | _UNPACK_UINT16_BE = struct.Struct(">H").unpack_from # Big-endian unsigned 16-bit 14 | _UNPACK_UINT24_BE = struct.Struct(">I").unpack # For 3-byte values (read as 4 bytes) 15 | 16 | 17 | def create_background_task(target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]: 18 | """Create a background task.""" 19 | task = asyncio.create_task(target) 20 | _BACKGROUND_TASKS.add(task) 21 | task.add_done_callback(_BACKGROUND_TASKS.remove) 22 | return task 23 | 24 | 25 | def parse_power_data( 26 | data: bytes, offset: int, scale: float = 1.0, mask: int | None = None 27 | ) -> float: 28 | """ 29 | Parse 2-byte power-related data from bytes. 30 | 31 | Args: 32 | data: Raw bytes data 33 | offset: Starting offset for the 2-byte value 34 | scale: Scale factor to divide the raw value by (default: 1.0) 35 | mask: Optional bitmask to apply (default: None) 36 | 37 | Returns: 38 | Parsed float value 39 | 40 | """ 41 | if offset + 2 > len(data): 42 | raise ValueError( 43 | f"Insufficient data: need at least {offset + 2} bytes, got {len(data)}" 44 | ) 45 | 46 | value = _UNPACK_UINT16_BE(data, offset)[0] 47 | if mask is not None: 48 | value &= mask 49 | return value / scale 50 | 51 | 52 | def parse_uint24_be(data: bytes, offset: int) -> int: 53 | """ 54 | Parse 3-byte big-endian unsigned integer. 55 | 56 | Args: 57 | data: Raw bytes data 58 | offset: Starting offset for the 3-byte value 59 | 60 | Returns: 61 | Parsed integer value 62 | 63 | """ 64 | if offset + 3 > len(data): 65 | raise ValueError( 66 | f"Insufficient data: need at least {offset + 3} bytes, got {len(data)}" 67 | ) 68 | 69 | # Read 3 bytes and pad with 0 at the beginning for 4-byte struct 70 | return _UNPACK_UINT24_BE(b"\x00" + data[offset : offset + 3])[0] 71 | 72 | 73 | def celsius_to_fahrenheit(celsius: float) -> float: 74 | """Convert temperature from Celsius to Fahrenheit.""" 75 | return (celsius * 9 / 5) + 32 76 | -------------------------------------------------------------------------------- /switchbot/models.py: -------------------------------------------------------------------------------- 1 | """Library to handle connection with Switchbot.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | from bleak.backends.device import BLEDevice 9 | 10 | 11 | @dataclass 12 | class SwitchBotAdvertisement: 13 | """Switchbot advertisement.""" 14 | 15 | address: str 16 | data: dict[str, Any] 17 | device: BLEDevice 18 | rssi: int 19 | active: bool = False 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from switchbot import SwitchbotModel 4 | 5 | 6 | @dataclass 7 | class AdvTestCase: 8 | manufacturer_data: bytes | None 9 | service_data: bytes 10 | data: dict 11 | model: str | bytes 12 | modelFriendlyName: str 13 | modelName: SwitchbotModel 14 | -------------------------------------------------------------------------------- /tests/test_air_purifier.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, MagicMock, patch 2 | 3 | import pytest 4 | from bleak.backends.device import BLEDevice 5 | 6 | from switchbot import SwitchBotAdvertisement, SwitchbotEncryptedDevice, SwitchbotModel 7 | from switchbot.const.air_purifier import AirPurifierMode 8 | from switchbot.devices import air_purifier 9 | 10 | from .test_adv_parser import generate_ble_device 11 | 12 | common_params = [ 13 | (b"7\x00\x00\x95-\x00", "7"), 14 | (b"*\x00\x00\x15\x04\x00", "*"), 15 | (b"+\x00\x00\x15\x04\x00", "+"), 16 | (b"8\x00\x00\x95-\x00", "8"), 17 | ] 18 | 19 | 20 | def create_device_for_command_testing( 21 | rawAdvData: bytes, model: str, init_data: dict | None = None 22 | ): 23 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 24 | device = air_purifier.SwitchbotAirPurifier( 25 | ble_device, "ff", "ffffffffffffffffffffffffffffffff" 26 | ) 27 | device.update_from_advertisement( 28 | make_advertisement_data(ble_device, rawAdvData, model, init_data) 29 | ) 30 | device._send_command = AsyncMock() 31 | device._check_command_result = MagicMock() 32 | device.update = AsyncMock() 33 | return device 34 | 35 | 36 | def make_advertisement_data( 37 | ble_device: BLEDevice, rawAdvData: bytes, model: str, init_data: dict | None = None 38 | ): 39 | """Set advertisement data with defaults.""" 40 | if init_data is None: 41 | init_data = {} 42 | 43 | return SwitchBotAdvertisement( 44 | address="aa:bb:cc:dd:ee:ff", 45 | data={ 46 | "rawAdvData": rawAdvData, 47 | "data": { 48 | "isOn": True, 49 | "mode": "level_3", 50 | "isAqiValid": False, 51 | "child_lock": False, 52 | "speed": 100, 53 | "aqi_level": "excellent", 54 | "filter element working time": 405, 55 | "err_code": 0, 56 | "sequence_number": 161, 57 | } 58 | | init_data, 59 | "isEncrypted": False, 60 | "model": model, 61 | "modelFriendlyName": "Air Purifier", 62 | "modelName": SwitchbotModel.AIR_PURIFIER, 63 | }, 64 | device=ble_device, 65 | rssi=-80, 66 | active=True, 67 | ) 68 | 69 | 70 | @pytest.mark.asyncio 71 | @pytest.mark.parametrize( 72 | ("rawAdvData", "model"), 73 | common_params, 74 | ) 75 | @pytest.mark.parametrize( 76 | "pm25", 77 | [150], 78 | ) 79 | async def test_status_from_proceess_adv(rawAdvData, model, pm25): 80 | device = create_device_for_command_testing(rawAdvData, model, {"pm25": pm25}) 81 | assert device.get_current_percentage() == 100 82 | assert device.is_on() is True 83 | assert device.get_current_aqi_level() == "excellent" 84 | assert device.get_current_mode() == "level_3" 85 | assert device.get_current_pm25() == 150 86 | 87 | 88 | @pytest.mark.asyncio 89 | @pytest.mark.parametrize( 90 | ("rawAdvData", "model"), 91 | common_params, 92 | ) 93 | async def test_get_basic_info_returns_none_when_no_data(rawAdvData, model): 94 | device = create_device_for_command_testing(rawAdvData, model) 95 | device._get_basic_info = AsyncMock(return_value=None) 96 | 97 | assert await device.get_basic_info() is None 98 | 99 | 100 | @pytest.mark.asyncio 101 | @pytest.mark.parametrize( 102 | ("rawAdvData", "model"), 103 | common_params, 104 | ) 105 | @pytest.mark.parametrize( 106 | "mode", ["level_1", "level_2", "level_3", "auto", "pet", "sleep"] 107 | ) 108 | async def test_set_preset_mode(rawAdvData, model, mode): 109 | device = create_device_for_command_testing(rawAdvData, model, {"mode": mode}) 110 | await device.set_preset_mode(mode) 111 | assert device.get_current_mode() == mode 112 | 113 | 114 | @pytest.mark.asyncio 115 | @pytest.mark.parametrize( 116 | ("rawAdvData", "model"), 117 | common_params, 118 | ) 119 | async def test_turn_on(rawAdvData, model): 120 | device = create_device_for_command_testing(rawAdvData, model, {"isOn": True}) 121 | await device.turn_on() 122 | assert device.is_on() is True 123 | 124 | 125 | @pytest.mark.asyncio 126 | @pytest.mark.parametrize( 127 | ("rawAdvData", "model"), 128 | common_params, 129 | ) 130 | async def test_turn_off(rawAdvData, model): 131 | device = create_device_for_command_testing(rawAdvData, model, {"isOn": False}) 132 | await device.turn_off() 133 | assert device.is_on() is False 134 | 135 | 136 | @pytest.mark.asyncio 137 | @pytest.mark.parametrize( 138 | ("rawAdvData", "model"), 139 | common_params, 140 | ) 141 | @pytest.mark.parametrize( 142 | ("response", "expected"), 143 | [ 144 | (b"\x00", None), 145 | (b"\x07", None), 146 | (b"\x01\x02\x03", b"\x01\x02\x03"), 147 | ], 148 | ) 149 | async def test__get_basic_info(rawAdvData, model, response, expected): 150 | device = create_device_for_command_testing(rawAdvData, model) 151 | device._send_command = AsyncMock(return_value=response) 152 | result = await device._get_basic_info() 153 | assert result == expected 154 | 155 | 156 | @pytest.mark.asyncio 157 | @pytest.mark.parametrize( 158 | ("rawAdvData", "model"), 159 | common_params, 160 | ) 161 | @pytest.mark.parametrize( 162 | ("basic_info", "result"), 163 | [ 164 | ( 165 | bytearray( 166 | b"\x01\xa7\xe9\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\xf0\x00\x00\x17" 167 | ), 168 | [True, 2, "level_2", True, False, "excellent", 50, 240, 2.3], 169 | ), 170 | ( 171 | bytearray( 172 | b"\x01\xa8\xec\x8c\x08\x00\xb2\x01\x96\x00\x00\x00\xf0\x00\x00\x17" 173 | ), 174 | [True, 2, "pet", True, False, "excellent", 50, 240, 2.3], 175 | ), 176 | ], 177 | ) 178 | async def test_get_basic_info(rawAdvData, model, basic_info, result): 179 | device = create_device_for_command_testing(rawAdvData, model) 180 | 181 | async def mock_get_basic_info(): 182 | return basic_info 183 | 184 | device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info) 185 | 186 | info = await device.get_basic_info() 187 | assert info["isOn"] == result[0] 188 | assert info["version_info"] == result[1] 189 | assert info["mode"] == result[2] 190 | assert info["isAqiValid"] == result[3] 191 | assert info["child_lock"] == result[4] 192 | assert info["aqi_level"] == result[5] 193 | assert info["speed"] == result[6] 194 | assert info["pm25"] == result[7] 195 | assert info["firmware"] == result[8] 196 | 197 | 198 | @pytest.mark.asyncio 199 | @patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock) 200 | async def test_verify_encryption_key(mock_parent_verify): 201 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 202 | key_id = "ff" 203 | encryption_key = "ffffffffffffffffffffffffffffffff" 204 | 205 | mock_parent_verify.return_value = True 206 | 207 | result = await air_purifier.SwitchbotAirPurifier.verify_encryption_key( 208 | device=ble_device, 209 | key_id=key_id, 210 | encryption_key=encryption_key, 211 | ) 212 | 213 | mock_parent_verify.assert_awaited_once_with( 214 | ble_device, 215 | key_id, 216 | encryption_key, 217 | SwitchbotModel.AIR_PURIFIER, 218 | ) 219 | 220 | assert result is True 221 | 222 | 223 | def test_get_modes(): 224 | assert AirPurifierMode.get_modes() == [ 225 | "level_1", 226 | "level_2", 227 | "level_3", 228 | "auto", 229 | "sleep", 230 | "pet", 231 | ] 232 | -------------------------------------------------------------------------------- /tests/test_base_cover.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, Mock 2 | 3 | import pytest 4 | from bleak.backends.device import BLEDevice 5 | 6 | from switchbot import SwitchBotAdvertisement, SwitchbotModel 7 | from switchbot.devices import base_cover, blind_tilt 8 | 9 | from .test_adv_parser import generate_ble_device 10 | 11 | 12 | def create_device_for_command_testing(position=50, calibration=True): 13 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 14 | base_cover_device = base_cover.SwitchbotBaseCover(False, ble_device) 15 | base_cover_device.update_from_advertisement( 16 | make_advertisement_data(ble_device, True, position, calibration) 17 | ) 18 | base_cover_device._send_multiple_commands = AsyncMock() 19 | base_cover_device.update = AsyncMock() 20 | return base_cover_device 21 | 22 | 23 | def make_advertisement_data( 24 | ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True 25 | ): 26 | """Set advertisement data with defaults.""" 27 | return SwitchBotAdvertisement( 28 | address="aa:bb:cc:dd:ee:ff", 29 | data={ 30 | "rawAdvData": b"c\xc0X\x00\x11\x04", 31 | "data": { 32 | "calibration": calibration, 33 | "battery": 88, 34 | "inMotion": in_motion, 35 | "tilt": position, 36 | "lightLevel": 1, 37 | "deviceChain": 1, 38 | }, 39 | "isEncrypted": False, 40 | "model": "c", 41 | "modelFriendlyName": "Curtain", 42 | "modelName": SwitchbotModel.CURTAIN, 43 | }, 44 | device=ble_device, 45 | rssi=-80, 46 | active=True, 47 | ) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_send_multiple_commands(): 52 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 53 | base_cover_device = base_cover.SwitchbotBaseCover(False, ble_device) 54 | base_cover_device.update_from_advertisement( 55 | make_advertisement_data(ble_device, True, 50, True) 56 | ) 57 | base_cover_device._send_command = AsyncMock() 58 | base_cover_device._check_command_result = Mock(return_value=True) 59 | await base_cover_device._send_multiple_commands(blind_tilt.OPEN_KEYS) 60 | assert base_cover_device._send_command.await_count == 2 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_stop(): 65 | base_cover_device = create_device_for_command_testing() 66 | await base_cover_device.stop() 67 | base_cover_device._send_multiple_commands.assert_awaited_once_with( 68 | base_cover.STOP_KEYS 69 | ) 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_set_position(): 74 | base_cover_device = create_device_for_command_testing() 75 | await base_cover_device.set_position(50) 76 | base_cover_device._send_multiple_commands.assert_awaited_once() 77 | 78 | 79 | @pytest.mark.asyncio 80 | @pytest.mark.parametrize("data_value", [(None), (b"\x07"), (b"\x00")]) 81 | async def test_get_extended_info_adv_returns_none_when_bad_data(data_value): 82 | base_cover_device = create_device_for_command_testing() 83 | base_cover_device._send_command = AsyncMock(return_value=data_value) 84 | assert await base_cover_device.get_extended_info_adv() is None 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_get_extended_info_adv_returns_single_device(): 89 | base_cover_device = create_device_for_command_testing() 90 | base_cover_device._send_command = AsyncMock( 91 | return_value=bytes([0, 50, 20, 0, 0, 0, 0]) 92 | ) 93 | ext_result = await base_cover_device.get_extended_info_adv() 94 | assert ext_result["device0"]["battery"] == 50 95 | assert ext_result["device0"]["firmware"] == 2 96 | assert "device1" not in ext_result 97 | 98 | 99 | @pytest.mark.asyncio 100 | async def test_get_extended_info_adv_returns_both_devices(): 101 | base_cover_device = create_device_for_command_testing() 102 | base_cover_device._send_command = AsyncMock( 103 | return_value=bytes([0, 50, 20, 0, 10, 30, 0]) 104 | ) 105 | ext_result = await base_cover_device.get_extended_info_adv() 106 | assert ext_result["device0"]["battery"] == 50 107 | assert ext_result["device0"]["firmware"] == 2 108 | assert ext_result["device1"]["battery"] == 10 109 | assert ext_result["device1"]["firmware"] == 3 110 | 111 | 112 | @pytest.mark.asyncio 113 | @pytest.mark.parametrize( 114 | ("data_value", "result"), 115 | [ 116 | (0, "not_charging"), 117 | (1, "charging_by_adapter"), 118 | (2, "charging_by_solar"), 119 | (3, "fully_charged"), 120 | (4, "solar_not_charging"), 121 | (5, "charging_error"), 122 | ], 123 | ) 124 | async def test_get_extended_info_adv_returns_device0_charge_states(data_value, result): 125 | base_cover_device = create_device_for_command_testing() 126 | base_cover_device._send_command = AsyncMock( 127 | return_value=bytes([0, 50, 20, data_value, 10, 30, 0]) 128 | ) 129 | ext_result = await base_cover_device.get_extended_info_adv() 130 | assert ext_result["device0"]["stateOfCharge"] == result 131 | 132 | 133 | @pytest.mark.asyncio 134 | @pytest.mark.parametrize( 135 | ("data_value", "result"), 136 | [ 137 | (0, "not_charging"), 138 | (1, "charging_by_adapter"), 139 | (2, "charging_by_solar"), 140 | (3, "fully_charged"), 141 | (4, "solar_not_charging"), 142 | (5, "charging_error"), 143 | ], 144 | ) 145 | async def test_get_extended_info_adv_returns_device1_charge_states(data_value, result): 146 | base_cover_device = create_device_for_command_testing() 147 | base_cover_device._send_command = AsyncMock( 148 | return_value=bytes([0, 50, 20, 0, 10, 30, data_value]) 149 | ) 150 | ext_result = await base_cover_device.get_extended_info_adv() 151 | assert ext_result["device1"]["stateOfCharge"] == result 152 | -------------------------------------------------------------------------------- /tests/test_blind_tilt.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | from bleak.backends.device import BLEDevice 5 | 6 | from switchbot import SwitchBotAdvertisement, SwitchbotModel 7 | from switchbot.devices import blind_tilt 8 | from switchbot.devices.base_cover import COVER_EXT_SUM_KEY 9 | 10 | from .test_adv_parser import generate_ble_device 11 | 12 | 13 | def create_device_for_command_testing( 14 | position=50, calibration=True, reverse_mode=False 15 | ): 16 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 17 | curtain_device = blind_tilt.SwitchbotBlindTilt( 18 | ble_device, reverse_mode=reverse_mode 19 | ) 20 | curtain_device.update_from_advertisement( 21 | make_advertisement_data(ble_device, True, position, calibration) 22 | ) 23 | curtain_device._send_multiple_commands = AsyncMock() 24 | curtain_device.update = AsyncMock() 25 | return curtain_device 26 | 27 | 28 | def make_advertisement_data( 29 | ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True 30 | ): 31 | """Set advertisement data with defaults.""" 32 | return SwitchBotAdvertisement( 33 | address="aa:bb:cc:dd:ee:ff", 34 | data={ 35 | "rawAdvData": b"c\xc0X\x00\x11\x04", 36 | "data": { 37 | "calibration": calibration, 38 | "battery": 88, 39 | "inMotion": in_motion, 40 | "tilt": position, 41 | "lightLevel": 1, 42 | "deviceChain": 1, 43 | }, 44 | "isEncrypted": False, 45 | "model": "c", 46 | "modelFriendlyName": "Curtain", 47 | "modelName": SwitchbotModel.CURTAIN, 48 | }, 49 | device=ble_device, 50 | rssi=-80, 51 | active=True, 52 | ) 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_open(): 57 | blind_device = create_device_for_command_testing() 58 | await blind_device.open() 59 | blind_device._send_multiple_commands.assert_awaited_once_with(blind_tilt.OPEN_KEYS) 60 | 61 | 62 | @pytest.mark.asyncio 63 | @pytest.mark.parametrize( 64 | ("position", "keys"), 65 | [(5, blind_tilt.CLOSE_DOWN_KEYS), (55, blind_tilt.CLOSE_UP_KEYS)], 66 | ) 67 | async def test_close(position, keys): 68 | blind_device = create_device_for_command_testing(position=position) 69 | await blind_device.close() 70 | blind_device._send_multiple_commands.assert_awaited_once_with(keys) 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_get_basic_info_returns_none_when_no_data(): 75 | blind_device = create_device_for_command_testing() 76 | blind_device._get_basic_info = AsyncMock(return_value=None) 77 | 78 | assert await blind_device.get_basic_info() is None 79 | 80 | 81 | @pytest.mark.asyncio 82 | @pytest.mark.parametrize( 83 | ("reverse_mode", "data", "result"), 84 | [ 85 | ( 86 | False, 87 | bytes([0, 1, 10, 2, 255, 255, 50, 4]), 88 | [1, 1, 1, 1, 1, True, False, False, True, 50, 4], 89 | ), 90 | ( 91 | False, 92 | bytes([0, 1, 10, 2, 0, 0, 50, 4]), 93 | [1, 1, 0, 0, 0, False, False, False, False, 50, 4], 94 | ), 95 | ( 96 | False, 97 | bytes([0, 1, 10, 2, 0, 1, 50, 4]), 98 | [1, 1, 0, 0, 1, False, True, False, True, 50, 4], 99 | ), 100 | ( 101 | True, 102 | bytes([0, 1, 10, 2, 255, 255, 50, 4]), 103 | [1, 1, 1, 1, 1, True, False, True, False, 50, 4], 104 | ), 105 | ( 106 | True, 107 | bytes([0, 1, 10, 2, 0, 0, 50, 4]), 108 | [1, 1, 0, 0, 0, False, False, False, False, 50, 4], 109 | ), 110 | ( 111 | True, 112 | bytes([0, 1, 10, 2, 0, 1, 50, 4]), 113 | [1, 1, 0, 0, 1, False, True, False, True, 50, 4], 114 | ), 115 | ], 116 | ) 117 | async def test_get_basic_info(reverse_mode, data, result): 118 | blind_device = create_device_for_command_testing(reverse_mode=reverse_mode) 119 | blind_device._get_basic_info = AsyncMock(return_value=data) 120 | 121 | info = await blind_device.get_basic_info() 122 | assert info["battery"] == result[0] 123 | assert info["firmware"] == result[1] 124 | assert info["light"] == result[2] 125 | assert info["fault"] == result[2] 126 | assert info["solarPanel"] == result[3] 127 | assert info["calibration"] == result[3] 128 | assert info["calibrated"] == result[3] 129 | assert info["inMotion"] == result[4] 130 | assert info["motionDirection"]["opening"] == result[5] 131 | assert info["motionDirection"]["closing"] == result[6] 132 | assert info["motionDirection"]["up"] == result[7] 133 | assert info["motionDirection"]["down"] == result[8] 134 | assert info["tilt"] == result[9] 135 | assert info["timers"] == result[10] 136 | 137 | 138 | @pytest.mark.asyncio 139 | async def test_get_extended_info_summary_sends_command(): 140 | blind_device = create_device_for_command_testing() 141 | blind_device._send_command = AsyncMock() 142 | await blind_device.get_extended_info_summary() 143 | blind_device._send_command.assert_awaited_once_with(key=COVER_EXT_SUM_KEY) 144 | 145 | 146 | @pytest.mark.asyncio 147 | @pytest.mark.parametrize("data_value", [(None), (b"\x07"), (b"\x00")]) 148 | async def test_get_extended_info_summary_returns_none_when_bad_data(data_value): 149 | blind_device = create_device_for_command_testing() 150 | blind_device._send_command = AsyncMock(return_value=data_value) 151 | assert await blind_device.get_extended_info_summary() is None 152 | 153 | 154 | @pytest.mark.asyncio 155 | @pytest.mark.parametrize( 156 | ("data", "result"), [(bytes([0, 0]), False), (bytes([0, 255]), True)] 157 | ) 158 | async def test_get_extended_info_summary(data, result): 159 | blind_device = create_device_for_command_testing() 160 | blind_device._send_command = AsyncMock(return_value=data) 161 | ext_result = await blind_device.get_extended_info_summary() 162 | assert ext_result["device0"]["light"] == result 163 | 164 | 165 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 166 | def test_device_passive_opening(reverse_mode): 167 | """Test passive opening advertisement.""" 168 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 169 | curtain_device = blind_tilt.SwitchbotBlindTilt( 170 | ble_device, reverse_mode=reverse_mode 171 | ) 172 | curtain_device.update_from_advertisement( 173 | make_advertisement_data(ble_device, True, 0) 174 | ) 175 | curtain_device.update_from_advertisement( 176 | make_advertisement_data(ble_device, True, 10) 177 | ) 178 | 179 | assert curtain_device.is_opening() is True 180 | assert curtain_device.is_closing() is False 181 | 182 | 183 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 184 | def test_device_passive_closing(reverse_mode): 185 | """Test passive closing advertisement.""" 186 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 187 | curtain_device = blind_tilt.SwitchbotBlindTilt( 188 | ble_device, reverse_mode=reverse_mode 189 | ) 190 | curtain_device.update_from_advertisement( 191 | make_advertisement_data(ble_device, True, 100) 192 | ) 193 | curtain_device.update_from_advertisement( 194 | make_advertisement_data(ble_device, True, 90) 195 | ) 196 | 197 | assert curtain_device.is_opening() is False 198 | assert curtain_device.is_closing() is True 199 | 200 | 201 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 202 | def test_device_passive_opening_then_stop(reverse_mode): 203 | """Test passive stopped after opening advertisement.""" 204 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 205 | curtain_device = blind_tilt.SwitchbotBlindTilt( 206 | ble_device, reverse_mode=reverse_mode 207 | ) 208 | curtain_device.update_from_advertisement( 209 | make_advertisement_data(ble_device, True, 0) 210 | ) 211 | curtain_device.update_from_advertisement( 212 | make_advertisement_data(ble_device, True, 10) 213 | ) 214 | curtain_device.update_from_advertisement( 215 | make_advertisement_data(ble_device, False, 10) 216 | ) 217 | 218 | assert curtain_device.is_opening() is False 219 | assert curtain_device.is_closing() is False 220 | 221 | 222 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 223 | def test_device_passive_closing_then_stop(reverse_mode): 224 | """Test passive stopped after closing advertisement.""" 225 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 226 | curtain_device = blind_tilt.SwitchbotBlindTilt( 227 | ble_device, reverse_mode=reverse_mode 228 | ) 229 | curtain_device.update_from_advertisement( 230 | make_advertisement_data(ble_device, True, 100) 231 | ) 232 | curtain_device.update_from_advertisement( 233 | make_advertisement_data(ble_device, True, 90) 234 | ) 235 | curtain_device.update_from_advertisement( 236 | make_advertisement_data(ble_device, False, 90) 237 | ) 238 | 239 | assert curtain_device.is_opening() is False 240 | assert curtain_device.is_closing() is False 241 | -------------------------------------------------------------------------------- /tests/test_fan.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | from bleak.backends.device import BLEDevice 5 | 6 | from switchbot import SwitchBotAdvertisement, SwitchbotModel 7 | from switchbot.const.fan import FanMode 8 | from switchbot.devices import fan 9 | 10 | from .test_adv_parser import generate_ble_device 11 | 12 | 13 | def create_device_for_command_testing(init_data: dict | None = None): 14 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 15 | fan_device = fan.SwitchbotFan(ble_device) 16 | fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data)) 17 | fan_device._send_command = AsyncMock() 18 | fan_device.update = AsyncMock() 19 | return fan_device 20 | 21 | 22 | def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None): 23 | """Set advertisement data with defaults.""" 24 | if init_data is None: 25 | init_data = {} 26 | 27 | return SwitchBotAdvertisement( 28 | address="aa:bb:cc:dd:ee:ff", 29 | data={ 30 | "rawAdvData": b"~\x00R", 31 | "data": { 32 | "isOn": True, 33 | "mode": "NORMAL", 34 | "nightLight": 3, 35 | "oscillating": False, 36 | "battery": 60, 37 | "speed": 50, 38 | } 39 | | init_data, 40 | "isEncrypted": False, 41 | "model": ",", 42 | "modelFriendlyName": "Circulator Fan", 43 | "modelName": SwitchbotModel.CIRCULATOR_FAN, 44 | }, 45 | device=ble_device, 46 | rssi=-80, 47 | active=True, 48 | ) 49 | 50 | 51 | @pytest.mark.asyncio 52 | @pytest.mark.parametrize( 53 | ("response", "expected"), 54 | [ 55 | (b"\x00", None), 56 | (b"\x07", None), 57 | (b"\x01\x02\x03", b"\x01\x02\x03"), 58 | ], 59 | ) 60 | async def test__get_basic_info(response, expected): 61 | fan_device = create_device_for_command_testing() 62 | fan_device._send_command = AsyncMock(return_value=response) 63 | result = await fan_device._get_basic_info(cmd="TEST_CMD") 64 | assert result == expected 65 | 66 | 67 | @pytest.mark.asyncio 68 | @pytest.mark.parametrize( 69 | ("basic_info", "firmware_info"), [(True, False), (False, True), (False, False)] 70 | ) 71 | async def test_get_basic_info_returns_none(basic_info, firmware_info): 72 | fan_device = create_device_for_command_testing() 73 | 74 | async def mock_get_basic_info(arg): 75 | if arg == fan.COMMAND_GET_BASIC_INFO: 76 | return basic_info 77 | if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY: 78 | return firmware_info 79 | return None 80 | 81 | fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info) 82 | 83 | assert await fan_device.get_basic_info() is None 84 | 85 | 86 | @pytest.mark.asyncio 87 | @pytest.mark.parametrize( 88 | ("basic_info", "firmware_info", "result"), 89 | [ 90 | ( 91 | bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"), 92 | bytearray(b"\x01W\x0b\x17\x01"), 93 | [87, True, False, "normal", 61, 1.1], 94 | ), 95 | ( 96 | bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"), 97 | bytearray(b"\x01U\x0b\x17\x01"), 98 | [85, True, True, "baby", 43, 1.1], 99 | ), 100 | ], 101 | ) 102 | async def test_get_basic_info(basic_info, firmware_info, result): 103 | fan_device = create_device_for_command_testing() 104 | 105 | async def mock_get_basic_info(arg): 106 | if arg == fan.COMMAND_GET_BASIC_INFO: 107 | return basic_info 108 | if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY: 109 | return firmware_info 110 | return None 111 | 112 | fan_device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info) 113 | 114 | info = await fan_device.get_basic_info() 115 | assert info["battery"] == result[0] 116 | assert info["isOn"] == result[1] 117 | assert info["oscillating"] == result[2] 118 | assert info["mode"] == result[3] 119 | assert info["speed"] == result[4] 120 | assert info["firmware"] == result[5] 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_set_preset_mode(): 125 | fan_device = create_device_for_command_testing({"mode": "baby"}) 126 | await fan_device.set_preset_mode("baby") 127 | assert fan_device.get_current_mode() == "baby" 128 | 129 | 130 | @pytest.mark.asyncio 131 | async def test_set_set_percentage_with_speed_is_0(): 132 | fan_device = create_device_for_command_testing({"speed": 0, "isOn": False}) 133 | await fan_device.turn_off() 134 | assert fan_device.get_current_percentage() == 0 135 | assert fan_device.is_on() is False 136 | 137 | 138 | @pytest.mark.asyncio 139 | async def test_set_set_percentage(): 140 | fan_device = create_device_for_command_testing({"speed": 80}) 141 | await fan_device.set_percentage(80) 142 | assert fan_device.get_current_percentage() == 80 143 | 144 | 145 | @pytest.mark.asyncio 146 | async def test_set_not_oscillation(): 147 | fan_device = create_device_for_command_testing({"oscillating": False}) 148 | await fan_device.set_oscillation(False) 149 | assert fan_device.get_oscillating_state() is False 150 | 151 | 152 | @pytest.mark.asyncio 153 | async def test_set_oscillation(): 154 | fan_device = create_device_for_command_testing({"oscillating": True}) 155 | await fan_device.set_oscillation(True) 156 | assert fan_device.get_oscillating_state() is True 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_turn_on(): 161 | fan_device = create_device_for_command_testing({"isOn": True}) 162 | await fan_device.turn_on() 163 | assert fan_device.is_on() is True 164 | 165 | 166 | @pytest.mark.asyncio 167 | async def test_turn_off(): 168 | fan_device = create_device_for_command_testing({"isOn": False}) 169 | await fan_device.turn_off() 170 | assert fan_device.is_on() is False 171 | 172 | 173 | def test_get_modes(): 174 | assert FanMode.get_modes() == ["normal", "natural", "sleep", "baby"] 175 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | """Tests for helper functions.""" 2 | 3 | import pytest 4 | 5 | from switchbot.helpers import parse_power_data 6 | 7 | 8 | def test_parse_power_data_basic(): 9 | """Test basic power data parsing.""" 10 | # Test data: bytes with value 0x1234 (4660 decimal) at offset 0 11 | data = b"\x12\x34\x56\x78" 12 | 13 | # Test without scale (should return raw value) 14 | assert parse_power_data(data, 0) == 4660 15 | 16 | # Test with scale of 10 17 | assert parse_power_data(data, 0, 10.0) == 466.0 18 | 19 | # Test with scale of 100 20 | assert parse_power_data(data, 0, 100.0) == 46.6 21 | 22 | 23 | def test_parse_power_data_with_offset(): 24 | """Test power data parsing with different offsets.""" 25 | data = b"\x00\x00\x12\x34\x56\x78" 26 | 27 | # Value at offset 2 should be 0x1234 28 | assert parse_power_data(data, 2, 10.0) == 466.0 29 | 30 | # Value at offset 4 should be 0x5678 31 | assert parse_power_data(data, 4, 10.0) == 2213.6 32 | 33 | 34 | def test_parse_power_data_with_mask(): 35 | """Test power data parsing with bitmask.""" 36 | # Test data: 0xFFFF 37 | data = b"\xff\xff" 38 | 39 | # Without mask 40 | assert parse_power_data(data, 0, 10.0) == 6553.5 41 | 42 | # With mask 0x7FFF (clear highest bit) 43 | assert parse_power_data(data, 0, 10.0, 0x7FFF) == 3276.7 44 | 45 | 46 | def test_parse_power_data_insufficient_data(): 47 | """Test error handling for insufficient data.""" 48 | data = b"\x12" # Only 1 byte 49 | 50 | # Should raise ValueError when trying to read 2 bytes 51 | with pytest.raises(ValueError, match="Insufficient data"): 52 | parse_power_data(data, 0) 53 | 54 | # Should also fail at offset 1 with 2-byte data 55 | data = b"\x12\x34" 56 | with pytest.raises(ValueError, match="Insufficient data"): 57 | parse_power_data(data, 1) # Would need to read bytes 1-2 58 | 59 | 60 | def test_parse_power_data_real_world_examples(): 61 | """Test with real-world examples from relay switch.""" 62 | # Simulate relay switch data structure 63 | raw_data = b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xdc\x00\x0f\x00\xe8" 64 | 65 | # Voltage at offset 9-10: 0x00DC = 220 / 10.0 = 22.0V 66 | assert parse_power_data(raw_data, 9, 10.0) == 22.0 67 | 68 | # Current at offset 11-12: 0x000F = 15 / 1000.0 = 0.015A 69 | assert parse_power_data(raw_data, 11, 1000.0) == 0.015 70 | 71 | # Power at offset 13-14: 0x00E8 = 232 / 10.0 = 23.2W 72 | assert parse_power_data(raw_data, 13, 10.0) == 23.2 73 | -------------------------------------------------------------------------------- /tests/test_hub2.py: -------------------------------------------------------------------------------- 1 | from switchbot.adv_parsers.hub2 import calculate_light_intensity 2 | 3 | 4 | def test_calculate_light_intensity(): 5 | """Test calculating light intensity from Hub 2 light level.""" 6 | # Test valid inputs 7 | assert calculate_light_intensity(1) == 0 8 | assert calculate_light_intensity(2) == 10 9 | assert calculate_light_intensity(10) == 90 10 | assert calculate_light_intensity(15) == 510 11 | assert calculate_light_intensity(21) == 1091 12 | 13 | # Test invalid inputs 14 | assert calculate_light_intensity(0) == 0 15 | assert calculate_light_intensity(22) == 0 16 | assert calculate_light_intensity(-1) == 0 17 | assert calculate_light_intensity(3.5) == 0 18 | assert calculate_light_intensity(None) == 0 19 | -------------------------------------------------------------------------------- /tests/test_hub3.py: -------------------------------------------------------------------------------- 1 | from switchbot.adv_parsers.hub3 import calculate_light_intensity 2 | 3 | 4 | def test_calculate_light_intensity(): 5 | """Test calculating light intensity from Hub 3 light level.""" 6 | # Test valid inputs 7 | assert calculate_light_intensity(1) == 0 8 | assert calculate_light_intensity(2) == 50 9 | assert calculate_light_intensity(5) == 317 10 | assert calculate_light_intensity(10) == 1023 11 | 12 | # Test invalid inputs 13 | assert calculate_light_intensity(0) == 0 14 | -------------------------------------------------------------------------------- /tests/test_lock.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from switchbot import SwitchbotModel 4 | from switchbot.devices import lock 5 | 6 | from .test_adv_parser import generate_ble_device 7 | 8 | 9 | def create_device_for_command_testing(model: str): 10 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 11 | return lock.SwitchbotLock( 12 | ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model 13 | ) 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "model", 18 | [ 19 | SwitchbotModel.LOCK, 20 | SwitchbotModel.LOCK_LITE, 21 | SwitchbotModel.LOCK_PRO, 22 | SwitchbotModel.LOCK_ULTRA, 23 | ], 24 | ) 25 | def test_lock_init(model: str): 26 | """Test the initialization of the lock device.""" 27 | device = create_device_for_command_testing(model) 28 | assert device._model == model 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "model", 33 | [ 34 | SwitchbotModel.AIR_PURIFIER, 35 | ], 36 | ) 37 | def test_lock_init_with_invalid_model(model: str): 38 | """Test that initializing with an invalid model raises ValueError.""" 39 | with pytest.raises( 40 | ValueError, match="initializing SwitchbotLock with a non-lock model" 41 | ): 42 | create_device_for_command_testing(model) 43 | -------------------------------------------------------------------------------- /tests/test_roller_shade.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | from bleak.backends.device import BLEDevice 5 | 6 | from switchbot import SwitchBotAdvertisement, SwitchbotModel 7 | from switchbot.devices import roller_shade 8 | 9 | from .test_adv_parser import generate_ble_device 10 | 11 | 12 | def create_device_for_command_testing( 13 | position=50, calibration=True, reverse_mode=False 14 | ): 15 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 16 | roller_shade_device = roller_shade.SwitchbotRollerShade( 17 | ble_device, reverse_mode=reverse_mode 18 | ) 19 | roller_shade_device.update_from_advertisement( 20 | make_advertisement_data(ble_device, True, position, calibration) 21 | ) 22 | roller_shade_device._send_multiple_commands = AsyncMock() 23 | roller_shade_device.update = AsyncMock() 24 | return roller_shade_device 25 | 26 | 27 | def make_advertisement_data( 28 | ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True 29 | ): 30 | """Set advertisement data with defaults.""" 31 | return SwitchBotAdvertisement( 32 | address="aa:bb:cc:dd:ee:ff", 33 | data={ 34 | "rawAdvData": b",\x00'\x9f\x11\x04", 35 | "data": { 36 | "battery": 39, 37 | "calibration": calibration, 38 | "deviceChain": 1, 39 | "inMotion": in_motion, 40 | "lightLevel": 1, 41 | "position": position, 42 | }, 43 | "isEncrypted": False, 44 | "model": ",", 45 | "modelFriendlyName": "Roller Shade", 46 | "modelName": SwitchbotModel.ROLLER_SHADE, 47 | }, 48 | device=ble_device, 49 | rssi=-80, 50 | active=True, 51 | ) 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_open(): 56 | roller_shade_device = create_device_for_command_testing() 57 | await roller_shade_device.open() 58 | assert roller_shade_device.is_opening() is True 59 | assert roller_shade_device.is_closing() is False 60 | roller_shade_device._send_multiple_commands.assert_awaited_once_with( 61 | roller_shade.OPEN_KEYS 62 | ) 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_close(): 67 | roller_shade_device = create_device_for_command_testing() 68 | await roller_shade_device.close() 69 | assert roller_shade_device.is_opening() is False 70 | assert roller_shade_device.is_closing() is True 71 | roller_shade_device._send_multiple_commands.assert_awaited_once_with( 72 | roller_shade.CLOSE_KEYS 73 | ) 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_get_basic_info_returns_none_when_no_data(): 78 | roller_shade_device = create_device_for_command_testing() 79 | roller_shade_device._get_basic_info = AsyncMock(return_value=None) 80 | 81 | assert await roller_shade_device.get_basic_info() is None 82 | 83 | 84 | @pytest.mark.asyncio 85 | @pytest.mark.parametrize( 86 | ("reverse_mode", "data", "result"), 87 | [ 88 | ( 89 | True, 90 | bytes([0, 1, 10, 2, 0, 50, 4]), 91 | [1, 1, 2, "anticlockwise", False, False, False, False, False, 50, 4], 92 | ), 93 | ( 94 | True, 95 | bytes([0, 1, 10, 2, 214, 50, 4]), 96 | [1, 1, 2, "clockwise", True, False, True, True, True, 50, 4], 97 | ), 98 | ], 99 | ) 100 | async def test_get_basic_info(reverse_mode, data, result): 101 | blind_device = create_device_for_command_testing(reverse_mode=reverse_mode) 102 | blind_device._get_basic_info = AsyncMock(return_value=data) 103 | 104 | info = await blind_device.get_basic_info() 105 | assert info["battery"] == result[0] 106 | assert info["firmware"] == result[1] 107 | assert info["chainLength"] == result[2] 108 | assert info["openDirection"] == result[3] 109 | assert info["fault"] == result[4] 110 | assert info["solarPanel"] == result[5] 111 | assert info["calibration"] == result[6] 112 | assert info["calibrated"] == result[7] 113 | assert info["inMotion"] == result[8] 114 | assert info["position"] == result[9] 115 | assert info["timers"] == result[10] 116 | 117 | 118 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 119 | def test_device_passive_closing(reverse_mode): 120 | """Test passive closing advertisement.""" 121 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 122 | curtain_device = roller_shade.SwitchbotRollerShade( 123 | ble_device, reverse_mode=reverse_mode 124 | ) 125 | curtain_device.update_from_advertisement( 126 | make_advertisement_data(ble_device, True, 100) 127 | ) 128 | curtain_device.update_from_advertisement( 129 | make_advertisement_data(ble_device, True, 90) 130 | ) 131 | 132 | assert curtain_device.is_opening() is False 133 | assert curtain_device.is_closing() is True 134 | 135 | 136 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 137 | def test_device_passive_opening_then_stop(reverse_mode): 138 | """Test passive stopped after opening advertisement.""" 139 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 140 | curtain_device = roller_shade.SwitchbotRollerShade( 141 | ble_device, reverse_mode=reverse_mode 142 | ) 143 | curtain_device.update_from_advertisement( 144 | make_advertisement_data(ble_device, True, 0) 145 | ) 146 | curtain_device.update_from_advertisement( 147 | make_advertisement_data(ble_device, True, 10) 148 | ) 149 | curtain_device.update_from_advertisement( 150 | make_advertisement_data(ble_device, False, 10) 151 | ) 152 | 153 | assert curtain_device.is_opening() is False 154 | assert curtain_device.is_closing() is False 155 | 156 | 157 | @pytest.mark.parametrize("reverse_mode", [(True), (False)]) 158 | def test_device_passive_closing_then_stop(reverse_mode): 159 | """Test passive stopped after closing advertisement.""" 160 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 161 | curtain_device = roller_shade.SwitchbotRollerShade( 162 | ble_device, reverse_mode=reverse_mode 163 | ) 164 | curtain_device.update_from_advertisement( 165 | make_advertisement_data(ble_device, True, 100) 166 | ) 167 | curtain_device.update_from_advertisement( 168 | make_advertisement_data(ble_device, True, 90) 169 | ) 170 | curtain_device.update_from_advertisement( 171 | make_advertisement_data(ble_device, False, 90) 172 | ) 173 | 174 | assert curtain_device.is_opening() is False 175 | assert curtain_device.is_closing() is False 176 | 177 | 178 | @pytest.mark.asyncio 179 | async def test_stop(): 180 | curtain_device = create_device_for_command_testing() 181 | await curtain_device.stop() 182 | assert curtain_device.is_opening() is False 183 | assert curtain_device.is_closing() is False 184 | curtain_device._send_multiple_commands.assert_awaited_once_with( 185 | roller_shade.STOP_KEYS 186 | ) 187 | 188 | 189 | @pytest.mark.asyncio 190 | async def test_set_position_opening(): 191 | curtain_device = create_device_for_command_testing(reverse_mode=True) 192 | await curtain_device.set_position(0) 193 | assert curtain_device.is_opening() is True 194 | assert curtain_device.is_closing() is False 195 | curtain_device._send_multiple_commands.assert_awaited_once() 196 | 197 | 198 | @pytest.mark.asyncio 199 | async def test_set_position_closing(): 200 | curtain_device = create_device_for_command_testing(reverse_mode=True) 201 | await curtain_device.set_position(100) 202 | assert curtain_device.is_opening() is False 203 | assert curtain_device.is_closing() is True 204 | curtain_device._send_multiple_commands.assert_awaited_once() 205 | 206 | 207 | def test_get_position(): 208 | curtain_device = create_device_for_command_testing() 209 | assert curtain_device.get_position() == 50 210 | 211 | 212 | def test_update_motion_direction_with_no_previous_position(): 213 | curtain_device = create_device_for_command_testing(reverse_mode=True) 214 | curtain_device._update_motion_direction(True, None, 100) 215 | assert curtain_device.is_opening() is False 216 | assert curtain_device.is_closing() is False 217 | 218 | 219 | def test_update_motion_direction_with_previous_position(): 220 | curtain_device = create_device_for_command_testing(reverse_mode=True) 221 | curtain_device._update_motion_direction(True, 50, 100) 222 | assert curtain_device.is_opening() is True 223 | assert curtain_device.is_closing() is False 224 | -------------------------------------------------------------------------------- /tests/test_vacuum.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import pytest 4 | from bleak.backends.device import BLEDevice 5 | 6 | from switchbot import SwitchBotAdvertisement 7 | from switchbot.adv_parser import SUPPORTED_TYPES 8 | from switchbot.devices import vacuum 9 | 10 | from .test_adv_parser import generate_ble_device 11 | 12 | common_params = [ 13 | (b".\x00d", ".", 2), 14 | (b"z\x00\x00", ".", 2), 15 | (b"3\x00\x00", ".", 2), 16 | (b"(\x00", "(", 1), 17 | (b"}\x00", "(", 1), 18 | ] 19 | 20 | 21 | def create_device_for_command_testing( 22 | protocol_version: int, rawAdvData: bytes, model: str 23 | ): 24 | ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") 25 | device = vacuum.SwitchbotVacuum(ble_device) 26 | device.update_from_advertisement( 27 | make_advertisement_data(ble_device, protocol_version, rawAdvData, model) 28 | ) 29 | device._send_command = AsyncMock() 30 | device.update = AsyncMock() 31 | return device 32 | 33 | 34 | def make_advertisement_data( 35 | ble_device: BLEDevice, protocol_version: int, rawAdvData: bytes, model: str 36 | ): 37 | """Set advertisement data with defaults.""" 38 | if protocol_version == 1: 39 | return SwitchBotAdvertisement( 40 | address="aa:bb:cc:dd:ee:ff", 41 | data={ 42 | "rawAdvData": rawAdvData, 43 | "data": { 44 | "sequence_number": 2, 45 | "dusbin_connected": False, 46 | "dustbin_bound": False, 47 | "network_connected": True, 48 | "battery": 100, 49 | "work_status": 0, 50 | }, 51 | "isEncrypted": False, 52 | "model": model, 53 | "modelFriendlyName": SUPPORTED_TYPES[model]["modelFriendlyName"], 54 | "modelName": SUPPORTED_TYPES[model]["modelName"], 55 | }, 56 | device=ble_device, 57 | rssi=-97, 58 | active=True, 59 | ) 60 | return SwitchBotAdvertisement( 61 | address="aa:bb:cc:dd:ee:ff", 62 | data={ 63 | "rawAdvData": rawAdvData, 64 | "data": { 65 | "soc_version": "1.1.083", 66 | "step": 0, 67 | "mqtt_connected": True, 68 | "battery": 100, 69 | "work_status": 15, 70 | }, 71 | "isEncrypted": False, 72 | "model": model, 73 | "modelFriendlyName": SUPPORTED_TYPES[model]["modelFriendlyName"], 74 | "modelName": SUPPORTED_TYPES[model]["modelName"], 75 | }, 76 | device=ble_device, 77 | rssi=-97, 78 | active=True, 79 | ) 80 | 81 | 82 | @pytest.mark.asyncio 83 | @pytest.mark.parametrize( 84 | ("rawAdvData", "model"), 85 | [(b".\x00d", "."), (b"z\x00\x00", "z"), (b"3\x00\x00", "3")], 86 | ) 87 | async def test_status_from_proceess_adv(rawAdvData, model, protocol_version=2): 88 | device = create_device_for_command_testing(protocol_version, rawAdvData, model) 89 | assert device.get_soc_version() == "1.1.083" 90 | assert device.get_last_step() == 0 91 | assert device.get_mqtt_connnect_status() is True 92 | assert device.get_battery() == 100 93 | assert device.get_work_status() == 15 94 | 95 | 96 | @pytest.mark.asyncio 97 | @pytest.mark.parametrize(("rawAdvData", "model"), [(b"(\x00", "("), (b"}\x00", "}")]) 98 | async def test_status_from_proceess_adv_k(rawAdvData, model, protocol_version=1): 99 | device = create_device_for_command_testing(protocol_version, rawAdvData, model) 100 | assert device.get_dustbin_bound_status() is False 101 | assert device.get_dustbin_connnected_status() is False 102 | assert device.get_network_connected_status() is True 103 | assert device.get_battery() == 100 104 | assert device.get_work_status() == 0 105 | 106 | 107 | @pytest.mark.asyncio 108 | @pytest.mark.parametrize(("rawAdvData", "model", "protocol_version"), common_params) 109 | async def test_clean_up(rawAdvData, model, protocol_version): 110 | device = create_device_for_command_testing(protocol_version, rawAdvData, model) 111 | await device.clean_up(protocol_version) 112 | device._send_command.assert_awaited_once_with( 113 | vacuum.COMMAND_CLEAN_UP[protocol_version] 114 | ) 115 | 116 | 117 | @pytest.mark.asyncio 118 | @pytest.mark.parametrize(("rawAdvData", "model", "protocol_version"), common_params) 119 | async def test_return_to_dock(rawAdvData, model, protocol_version): 120 | device = create_device_for_command_testing(protocol_version, rawAdvData, model) 121 | await device.return_to_dock(protocol_version) 122 | device._send_command.assert_awaited_once_with( 123 | vacuum.COMMAND_RETURN_DOCK[protocol_version] 124 | ) 125 | 126 | 127 | @pytest.mark.asyncio 128 | @pytest.mark.parametrize(("rawAdvData", "model", "protocol_version"), common_params) 129 | async def test_get_basic_info_returns_none_when_no_data( 130 | rawAdvData, model, protocol_version 131 | ): 132 | device = create_device_for_command_testing(protocol_version, rawAdvData, model) 133 | device._get_basic_info = AsyncMock(return_value=None) 134 | 135 | assert await device.get_basic_info() is None 136 | --------------------------------------------------------------------------------