pax_global_header00006660000000000000000000000064147653511010014515gustar00rootroot0000000000000052 comment=03809ebd6ec3151476cc7aef2852469ae9a933db pySwitchbot-0.57.1/000077500000000000000000000000001476535110100141265ustar00rootroot00000000000000pySwitchbot-0.57.1/.github/000077500000000000000000000000001476535110100154665ustar00rootroot00000000000000pySwitchbot-0.57.1/.github/dependabot.yml000066400000000000000000000013441476535110100203200ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" commit-message: prefix: "chore(ci): " groups: github-actions: patterns: - "*" - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" pySwitchbot-0.57.1/.github/workflows/000077500000000000000000000000001476535110100175235ustar00rootroot00000000000000pySwitchbot-0.57.1/.github/workflows/ci.yaml000066400000000000000000000015011476535110100207770ustar00rootroot00000000000000name: CI on: pull_request: push: branches: [master] jobs: coverage: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements_dev.txt . - uses: pre-commit/action@v3.0.1 - name: Tests run: pytest --cov=switchbot --cov-report=term-missing --cov-report=xml tests - name: Upload coverage to Codecov uses: codecov/codecov-action@v5.4.0 with: token: ${{ secrets.CODECOV_TOKEN }} # required pySwitchbot-0.57.1/.github/workflows/python-publish.yml000066400000000000000000000027441476535110100232420ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install pypa/build run: >- python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ deploy: permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing runs-on: ubuntu-latest needs: - build name: >- Publish Python 🐍 distribution 📦 to PyPI environment: name: pypi url: https://pypi.org/p/pySwitchbot steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pySwitchbot-0.57.1/.gitignore000066400000000000000000000024651476535110100161250ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ switchbot/.vs/slnx.sqlite switchbot/.vs/switchbot/v16/.suo switchbot/.vs/VSWorkspaceState.json switchbot/.vs/ProjectSettings.json pySwitchbot-0.57.1/.isort.cfg000066400000000000000000000000311476535110100160170ustar00rootroot00000000000000[settings] profile=black pySwitchbot-0.57.1/.pre-commit-config.yaml000066400000000000000000000031221476535110100204050ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks exclude: "CHANGELOG.md" default_stages: [pre-commit] ci: autofix_commit_msg: "chore(pre-commit.ci): auto fixes" autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" repos: - repo: https://github.com/commitizen-tools/commitizen rev: v4.4.1 hooks: - id: commitizen stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first - id: check-json - id: check-toml - id: check-xml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - id: debug-statements - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier - repo: https://github.com/asottile/pyupgrade rev: v3.19.1 hooks: - id: pyupgrade args: [--py311-plus] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.10 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/cdce8p/python-typing-update rev: v0.7.1 hooks: - id: python-typing-update stages: [manual] args: - --py311-plus - --force - --keep-updates files: ^(switchbot)/.+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell pySwitchbot-0.57.1/.travis.yml000066400000000000000000000004521476535110100162400ustar00rootroot00000000000000sudo: false matrix: fast_finish: true include: - python: "3.5" env: TOXENV=lint cache: directories: - "$HOME/.cache/pip" install: - pip install flake8 pylint bluepy language: python script: - flake8 switchbot --max-line-length=120 - pylint switchbot --max-line-length=120 pySwitchbot-0.57.1/LICENSE000066400000000000000000000020661476535110100151370ustar00rootroot00000000000000MIT License Copyright (c) 2018 Daniel Høyer Iversen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pySwitchbot-0.57.1/MANIFEST.in000066400000000000000000000000201476535110100156540ustar00rootroot00000000000000include LICENSE pySwitchbot-0.57.1/README.md000066400000000000000000000020111476535110100153770ustar00rootroot00000000000000# pySwitchbot [![Build Status](https://travis-ci.org/sblibs/pySwitchbot.svg?branch=master)](https://travis-ci.org/sblibs/pySwitchbot) Library to control Switchbot IoT devices https://www.switch-bot.com/bot ## Obtaining locks encryption key Using the script `scripts/get_encryption_key.py` you can manually obtain locks encryption key. Usage: ```shell $ python3 get_encryption_key.py MAC USERNAME Key ID: xx Encryption key: xxxxxxxxxxxxxxxx ``` Where `MAC` is MAC address of the lock and `USERNAME` is your SwitchBot account username, after that script will ask for your password. If authentication succeeds then script should output your key id and encryption key. Examples: - WoLock ```python import asyncio from switchbot.discovery import GetSwitchbotDevices from switchbot.devices import lock async def main(): wolock = await GetSwitchbotDevices().get_locks() await lock.SwitchbotLock(wolock['32C0F607-18B8-xxxx-xxxx-xxxxxxxxxx'].device, "key-id", "encryption-key").get_lock_status() asyncio.run(main()) ``` pySwitchbot-0.57.1/pyproject.toml000066400000000000000000000036271476535110100170520ustar00rootroot00000000000000[tool.ruff] target-version = "py311" line-length = 88 [tool.ruff.lint] ignore = [ "S101", # use of assert "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "D100", # Missing docstring in public module "D101", # Missing docstring in public module "D102", # Missing docstring in public method "D103", # Missing docstring in public module "D104", # Missing docstring in public package "D105", # Missing docstring in magic method "D107", # Missing docstring in `__init__` "D400", # First line should end with a period "D401", # First line of docstring should be in imperative mood "D205", # 1 blank line required between summary line and description "D415", # First line should end with a period, question mark, or exclamation point "D417", # Missing argument descriptions in the docstring "E501", # Line too long "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` "B008", # Do not perform function call "S110", # `try`-`except`-`pass` detected, consider logging the exception "D106", # Missing docstring in public nested class "UP007", # typer needs Optional syntax "UP038", # Use `X | Y` in `isinstance` is slower "S603", # check for execution of untrusted input "S105", # possible hard coded creds ] select = [ "B", # flake8-bugbear "D", # flake8-docstrings "C4", # flake8-comprehensions "S", # flake8-bandit "F", # pyflake "E", # pycodestyle "W", # pycodestyle "UP", # pyupgrade "I", # isort "RUF", # ruff specific ] [tool.ruff.lint.per-file-ignores] "tests/**/*" = [ "D100", "D101", "D102", "D103", "D104", "S101", ] "setup.py" = ["D100"] "conftest.py" = ["D100"] "docs/conf.py" = ["D100"] [tool.ruff.lint.isort] known-first-party = ["pySwitchbot", "tests"] pySwitchbot-0.57.1/requirements.txt000066400000000000000000000001171476535110100174110ustar00rootroot00000000000000aiohttp>=3.9.5 bleak>=0.17.0 bleak-retry-connector>=2.9.0 cryptography>=38.0.3 pySwitchbot-0.57.1/requirements_dev.txt000066400000000000000000000001511476535110100202450ustar00rootroot00000000000000pytest-asyncio pytest-cov aiohttp>=3.9.5 bleak>=0.17.0 bleak-retry-connector>=3.4.0 cryptography>=38.0.3 pySwitchbot-0.57.1/scripts/000077500000000000000000000000001476535110100156155ustar00rootroot00000000000000pySwitchbot-0.57.1/scripts/get_encryption_key.py000077500000000000000000000012111476535110100220660ustar00rootroot00000000000000#!/usr/bin/env python3 import getpass import sys from switchbot import SwitchbotLock def main(): if len(sys.argv) < 3: print(f"Usage: {sys.argv[0]} []") exit(1) if len(sys.argv) == 3: password = getpass.getpass() else: password = sys.argv[3] try: result = SwitchbotLock.retrieve_encryption_key( sys.argv[1], sys.argv[2], password ) except RuntimeError as e: print(e) exit(1) print("Key ID: " + result["key_id"]) print("Encryption key: " + result["encryption_key"]) if __name__ == "__main__": main() pySwitchbot-0.57.1/setup.py000066400000000000000000000022121476535110100156350ustar00rootroot00000000000000from pathlib import Path from setuptools import setup this_directory = Path(__file__).parent long_description = (this_directory / "README.md").read_text() setup( name="PySwitchbot", packages=[ "switchbot", "switchbot.devices", "switchbot.const", "switchbot.adv_parsers", ], install_requires=[ "aiohttp>=3.9.5", "bleak>=0.19.0", "bleak-retry-connector>=3.4.0", "cryptography>=39.0.0", "pyOpenSSL>=23.0.0", ], version="0.57.1", description="A library to communicate with Switchbot", long_description=long_description, long_description_content_type="text/markdown", author="Daniel Hjelseth Hoyer", url="https://github.com/sblibs/pySwitchbot/", license="MIT", python_requires=">=3.11", classifiers=[ "Development Status :: 3 - Alpha", "Environment :: Other Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Home Automation", "Topic :: Software Development :: Libraries :: Python Modules", ], ) pySwitchbot-0.57.1/switchbot/000077500000000000000000000000001476535110100161345ustar00rootroot00000000000000pySwitchbot-0.57.1/switchbot/__init__.py000066400000000000000000000037741476535110100202600ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations from bleak_retry_connector import ( close_stale_connections, close_stale_connections_by_address, get_device, ) from .adv_parser import SwitchbotSupportedType, parse_advertisement_data from .const import ( LockStatus, SwitchbotAccountConnectionError, SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotModel, ) from .devices.base_light import SwitchbotBaseLight from .devices.blind_tilt import SwitchbotBlindTilt from .devices.bot import Switchbot from .devices.bulb import SwitchbotBulb from .devices.ceiling_light import SwitchbotCeilingLight from .devices.curtain import SwitchbotCurtain from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier from .devices.humidifier import SwitchbotHumidifier from .devices.light_strip import SwitchbotLightStrip from .devices.lock import SwitchbotLock from .devices.plug import SwitchbotPlugMini from .devices.relay_switch import SwitchbotRelaySwitch from .discovery import GetSwitchbotDevices from .models import SwitchBotAdvertisement __all__ = [ "ColorMode", "GetSwitchbotDevices", "LockStatus", "SwitchBotAdvertisement", "Switchbot", "Switchbot", "SwitchbotAccountConnectionError", "SwitchbotApiError", "SwitchbotAuthenticationError", "SwitchbotBaseLight", "SwitchbotBlindTilt", "SwitchbotBulb", "SwitchbotCeilingLight", "SwitchbotCurtain", "SwitchbotDevice", "SwitchbotEncryptedDevice", "SwitchbotEvaporativeHumidifier", "SwitchbotHumidifier", "SwitchbotLightStrip", "SwitchbotLock", "SwitchbotModel", "SwitchbotModel", "SwitchbotPlugMini", "SwitchbotPlugMini", "SwitchbotRelaySwitch", "SwitchbotSupportedType", "SwitchbotSupportedType", "close_stale_connections", "close_stale_connections_by_address", "get_device", "parse_advertisement_data", ] pySwitchbot-0.57.1/switchbot/adv_parser.py000066400000000000000000000231561476535110100206430ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import logging from collections.abc import Callable from functools import lru_cache from typing import Any, TypedDict from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from .adv_parsers.blind_tilt import process_woblindtilt from .adv_parsers.bot import process_wohand from .adv_parsers.bulb import process_color_bulb from .adv_parsers.ceiling_light import process_woceiling from .adv_parsers.contact import process_wocontact from .adv_parsers.curtain import process_wocurtain from .adv_parsers.hub2 import process_wohub2 from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier from .adv_parsers.keypad import process_wokeypad from .adv_parsers.leak import process_leak from .adv_parsers.light_strip import process_wostrip from .adv_parsers.lock import process_wolock, process_wolock_pro from .adv_parsers.meter import process_wosensorth, process_wosensorth_c from .adv_parsers.motion import process_wopresence from .adv_parsers.plug import process_woplugmini from .adv_parsers.relay_switch import ( process_worelay_switch_1, process_worelay_switch_1pm, ) from .adv_parsers.remote import process_woremote from .const import SwitchbotModel from .models import SwitchBotAdvertisement _LOGGER = logging.getLogger(__name__) SERVICE_DATA_ORDER = ( "0000fd3d-0000-1000-8000-00805f9b34fb", "00000d00-0000-1000-8000-00805f9b34fb", ) MFR_DATA_ORDER = (2409, 741, 89) class SwitchbotSupportedType(TypedDict): """Supported type of Switchbot.""" modelName: SwitchbotModel modelFriendlyName: str func: Callable[[bytes, bytes | None], dict[str, bool | int]] manufacturer_id: int | None manufacturer_data_length: int | None SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = { "d": { "modelName": SwitchbotModel.CONTACT_SENSOR, "modelFriendlyName": "Contact Sensor", "func": process_wocontact, "manufacturer_id": 2409, }, "H": { "modelName": SwitchbotModel.BOT, "modelFriendlyName": "Bot", "func": process_wohand, "manufacturer_id": 89, }, "s": { "modelName": SwitchbotModel.MOTION_SENSOR, "modelFriendlyName": "Motion Sensor", "func": process_wopresence, "manufacturer_id": 2409, }, "r": { "modelName": SwitchbotModel.LIGHT_STRIP, "modelFriendlyName": "Light Strip", "func": process_wostrip, "manufacturer_id": 2409, }, "{": { "modelName": SwitchbotModel.CURTAIN, "modelFriendlyName": "Curtain 3", "func": process_wocurtain, "manufacturer_id": 2409, }, "c": { "modelName": SwitchbotModel.CURTAIN, "modelFriendlyName": "Curtain", "func": process_wocurtain, "manufacturer_id": 2409, }, "w": { "modelName": SwitchbotModel.IO_METER, "modelFriendlyName": "Indoor/Outdoor Meter", "func": process_wosensorth, "manufacturer_id": 2409, }, "i": { "modelName": SwitchbotModel.METER, "modelFriendlyName": "Meter Plus", "func": process_wosensorth, "manufacturer_id": 2409, }, "T": { "modelName": SwitchbotModel.METER, "modelFriendlyName": "Meter", "func": process_wosensorth, "manufacturer_id": 2409, }, "4": { "modelName": SwitchbotModel.METER_PRO, "modelFriendlyName": "Meter", "func": process_wosensorth, "manufacturer_id": 2409, }, "5": { "modelName": SwitchbotModel.METER_PRO_C, "modelFriendlyName": "Meter", "func": process_wosensorth_c, "manufacturer_id": 2409, }, "v": { "modelName": SwitchbotModel.HUB2, "modelFriendlyName": "Hub 2", "func": process_wohub2, "manufacturer_id": 2409, }, "g": { "modelName": SwitchbotModel.PLUG_MINI, "modelFriendlyName": "Plug Mini", "func": process_woplugmini, "manufacturer_id": 2409, }, "j": { "modelName": SwitchbotModel.PLUG_MINI, "modelFriendlyName": "Plug Mini (JP)", "func": process_woplugmini, "manufacturer_id": 2409, }, "u": { "modelName": SwitchbotModel.COLOR_BULB, "modelFriendlyName": "Color Bulb", "func": process_color_bulb, "manufacturer_id": 2409, }, "q": { "modelName": SwitchbotModel.CEILING_LIGHT, "modelFriendlyName": "Ceiling Light", "func": process_woceiling, "manufacturer_id": 2409, }, "n": { "modelName": SwitchbotModel.CEILING_LIGHT, "modelFriendlyName": "Ceiling Light Pro", "func": process_woceiling, "manufacturer_id": 2409, }, "e": { "modelName": SwitchbotModel.HUMIDIFIER, "modelFriendlyName": "Humidifier", "func": process_wohumidifier, "manufacturer_id": 741, "manufacturer_data_length": 6, }, "#": { "modelName": SwitchbotModel.EVAPORATIVE_HUMIDIFIER, "modelFriendlyName": "Evaporative Humidifier", "func": process_evaporative_humidifier, "manufacturer_id": 2409, }, "o": { "modelName": SwitchbotModel.LOCK, "modelFriendlyName": "Lock", "func": process_wolock, "manufacturer_id": 2409, }, "$": { "modelName": SwitchbotModel.LOCK_PRO, "modelFriendlyName": "Lock Pro", "func": process_wolock_pro, "manufacturer_id": 2409, }, "x": { "modelName": SwitchbotModel.BLIND_TILT, "modelFriendlyName": "Blind Tilt", "func": process_woblindtilt, "manufacturer_id": 2409, }, "&": { "modelName": SwitchbotModel.LEAK, "modelFriendlyName": "Leak Detector", "func": process_leak, "manufacturer_id": 2409, }, "y": { "modelName": SwitchbotModel.KEYPAD, "modelFriendlyName": "Keypad", "func": process_wokeypad, "manufacturer_id": 2409, }, "<": { "modelName": SwitchbotModel.RELAY_SWITCH_1PM, "modelFriendlyName": "Relay Switch 1PM", "func": process_worelay_switch_1pm, "manufacturer_id": 2409, }, ";": { "modelName": SwitchbotModel.RELAY_SWITCH_1, "modelFriendlyName": "Relay Switch 1", "func": process_worelay_switch_1, "manufacturer_id": 2409, }, "b": { "modelName": SwitchbotModel.REMOTE, "modelFriendlyName": "Remote", "func": process_woremote, "manufacturer_id": 89, }, } _SWITCHBOT_MODEL_TO_CHAR = { model_data["modelName"]: model_chr for model_chr, model_data in SUPPORTED_TYPES.items() } MODELS_BY_MANUFACTURER_DATA: dict[int, list[tuple[str, SwitchbotSupportedType]]] = { mfr_id: [] for mfr_id in MFR_DATA_ORDER } for model_chr, model in SUPPORTED_TYPES.items(): if "manufacturer_id" in model: mfr_id = model["manufacturer_id"] MODELS_BY_MANUFACTURER_DATA[mfr_id].append((model_chr, model)) def parse_advertisement_data( device: BLEDevice, advertisement_data: AdvertisementData, model: SwitchbotModel | None = None, ) -> SwitchBotAdvertisement | None: """Parse advertisement data.""" service_data = advertisement_data.service_data _service_data = None for uuid in SERVICE_DATA_ORDER: if uuid in service_data: _service_data = service_data[uuid] break _mfr_data = None _mfr_id = None for mfr_id in MFR_DATA_ORDER: if mfr_id in advertisement_data.manufacturer_data: _mfr_id = mfr_id _mfr_data = advertisement_data.manufacturer_data[mfr_id] break if _mfr_data is None and _service_data is None: return None try: data = _parse_data( _service_data, _mfr_data, _mfr_id, model, ) except Exception as err: # pylint: disable=broad-except _LOGGER.exception( "Failed to parse advertisement data: %s: %s", advertisement_data, err ) return None if not data: return None return SwitchBotAdvertisement( device.address, data, device, advertisement_data.rssi, bool(_service_data) ) @lru_cache(maxsize=128) def _parse_data( _service_data: bytes | None, _mfr_data: bytes | None, _mfr_id: int | None = None, _switchbot_model: SwitchbotModel | None = None, ) -> dict[str, Any] | None: """Parse advertisement data.""" _model = chr(_service_data[0] & 0b01111111) if _service_data else None if _switchbot_model and _switchbot_model in _SWITCHBOT_MODEL_TO_CHAR: _model = _SWITCHBOT_MODEL_TO_CHAR[_switchbot_model] if not _model and _mfr_id and _mfr_id in MODELS_BY_MANUFACTURER_DATA: for model_chr, model_data in MODELS_BY_MANUFACTURER_DATA[_mfr_id]: if model_data.get("manufacturer_data_length") == len(_mfr_data): _model = model_chr break if not _model: return None _isEncrypted = bool(_service_data[0] & 0b10000000) if _service_data else False data = { "rawAdvData": _service_data, "data": {}, "model": _model, "isEncrypted": _isEncrypted, } type_data = SUPPORTED_TYPES.get(_model) if type_data: model_data = type_data["func"](_service_data, _mfr_data) if model_data: data.update( { "modelFriendlyName": type_data["modelFriendlyName"], "modelName": type_data["modelName"], "data": model_data, } ) return data pySwitchbot-0.57.1/switchbot/adv_parsers/000077500000000000000000000000001476535110100204455ustar00rootroot00000000000000pySwitchbot-0.57.1/switchbot/adv_parsers/__init__.py000066400000000000000000000000561476535110100225570ustar00rootroot00000000000000"""Switchbot Advertisement Parser Library.""" pySwitchbot-0.57.1/switchbot/adv_parsers/blind_tilt.py000066400000000000000000000014731476535110100231500ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations def process_woblindtilt( data: bytes | None, mfr_data: bytes | None, reverse: bool = False ) -> dict[str, bool | int]: """Process woBlindTilt services data.""" if mfr_data is None: return {} device_data = mfr_data[6:] _tilt = max(min(device_data[2] & 0b01111111, 100), 0) _in_motion = bool(device_data[2] & 0b10000000) _light_level = (device_data[1] >> 4) & 0b00001111 _calibrated = bool(device_data[1] & 0b00000001) return { "calibration": _calibrated, "battery": data[2] & 0b01111111 if data else None, "inMotion": _in_motion, "tilt": (100 - _tilt) if reverse else _tilt, "lightLevel": _light_level, "sequence_number": device_data[0], } pySwitchbot-0.57.1/switchbot/adv_parsers/bot.py000066400000000000000000000011751476535110100216070ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations def process_wohand(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: """Process woHand/Bot services data.""" if data is None and mfr_data is None: return {} if data is None: return { "switchMode": None, "isOn": None, "battery": None, } _switch_mode = bool(data[1] & 0b10000000) return { "switchMode": _switch_mode, "isOn": not bool(data[1] & 0b01000000) if _switch_mode else False, "battery": data[2] & 0b01111111, } pySwitchbot-0.57.1/switchbot/adv_parsers/bulb.py000066400000000000000000000011711476535110100217430ustar00rootroot00000000000000"""Bulb parser.""" from __future__ import annotations def process_color_bulb( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoBulb services data.""" if mfr_data is None: return {} return { "sequence_number": mfr_data[6], "isOn": bool(mfr_data[7] & 0b10000000), "brightness": mfr_data[7] & 0b01111111, "delay": bool(mfr_data[8] & 0b10000000), "preset": bool(mfr_data[8] & 0b00001000), "color_mode": mfr_data[8] & 0b00000111, "speed": mfr_data[9] & 0b01111111, "loop_index": mfr_data[10] & 0b11111110, } pySwitchbot-0.57.1/switchbot/adv_parsers/ceiling_light.py000066400000000000000000000012341476535110100236200ustar00rootroot00000000000000"""Ceiling Light adv parser.""" from __future__ import annotations import logging _LOGGER = logging.getLogger(__name__) # Off d94b2d012b3c4864106124 # on d94b2d012b3c4a641061a4 # Off d94b2d012b3c4b64106124 # on d94b2d012b3c4d641061a4 # 00112233445566778899AA def process_woceiling(data: bytes, mfr_data: bytes | None) -> dict[str, bool | int]: """Process WoCeiling services data.""" if mfr_data is None: return {} return { "sequence_number": mfr_data[6], "isOn": bool(mfr_data[10] & 0b10000000), "brightness": mfr_data[7] & 0b01111111, "cw": int(mfr_data[8:10].hex(), 16), "color_mode": 1, } pySwitchbot-0.57.1/switchbot/adv_parsers/contact.py000066400000000000000000000023671476535110100224620ustar00rootroot00000000000000"""Contact sensor parser.""" from __future__ import annotations def process_wocontact( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process woContact Sensor services data.""" if data is None and mfr_data is None: return {} battery = data[2] & 0b01111111 if data else None tested = bool(data[1] & 0b10000000) if data else None if mfr_data and len(mfr_data) >= 13: motion_detected = bool(mfr_data[7] & 0b10000000) contact_open = bool(mfr_data[7] & 0b00010000) contact_timeout = bool(mfr_data[7] & 0b00100000) button_count = mfr_data[12] & 0b00001111 is_light = bool(mfr_data[7] & 0b01000000) else: motion_detected = bool(data[1] & 0b01000000) contact_open = bool(data[3] & 0b00000010) contact_timeout = bool(data[3] & 0b00000100) button_count = data[8] & 0b00001111 is_light = bool(data[3] & 0b00000001) return { "tested": tested, "motion_detected": motion_detected, "battery": battery, "contact_open": contact_open or contact_timeout, # timeout still means its open "contact_timeout": contact_timeout, "is_light": is_light, "button_count": button_count, } pySwitchbot-0.57.1/switchbot/adv_parsers/curtain.py000066400000000000000000000022361476535110100224670ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations def process_wocurtain( data: bytes | None, mfr_data: bytes | None, reverse: bool = True ) -> dict[str, bool | int]: """Process woCurtain/Curtain services data.""" if mfr_data and len(mfr_data) >= 13: # Curtain 3 device_data = mfr_data[8:11] battery_data = mfr_data[12] elif mfr_data and len(mfr_data) >= 11: device_data = mfr_data[8:11] battery_data = data[2] if data else None elif data: device_data = data[3:6] battery_data = data[2] else: return {} _position = max(min(device_data[0] & 0b01111111, 100), 0) _in_motion = bool(device_data[0] & 0b10000000) _light_level = (device_data[1] >> 4) & 0b00001111 _device_chain = device_data[1] & 0b00000111 return { "calibration": bool(data[1] & 0b01000000) if data else None, "battery": battery_data & 0b01111111 if battery_data is not None else None, "inMotion": _in_motion, "position": (100 - _position) if reverse else _position, "lightLevel": _light_level, "deviceChain": _device_chain, } pySwitchbot-0.57.1/switchbot/adv_parsers/hub2.py000066400000000000000000000020371476535110100216610ustar00rootroot00000000000000"""Hub2 parser.""" from __future__ import annotations from typing import Any def process_wohub2(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: """Process woHub2 sensor manufacturer data.""" temp_data = None if mfr_data: status = mfr_data[12] temp_data = mfr_data[13:16] if not temp_data: return {} _temp_sign = 1 if temp_data[1] & 0b10000000 else -1 _temp_c = _temp_sign * ( (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10) ) _temp_f = (_temp_c * 9 / 5) + 32 _temp_f = (_temp_f * 10) / 10 humidity = temp_data[2] & 0b01111111 light_level = status & 0b11111 if _temp_c == 0 and humidity == 0: return {} _wohub2_data = { # Data should be flat, but we keep the original structure for now "temp": {"c": _temp_c, "f": _temp_f}, "temperature": _temp_c, "fahrenheit": bool(temp_data[2] & 0b10000000), "humidity": humidity, "lightLevel": light_level, } return _wohub2_data pySwitchbot-0.57.1/switchbot/adv_parsers/humidifier.py000066400000000000000000000054251476535110100231520ustar00rootroot00000000000000"""Humidifier adv parser.""" from __future__ import annotations import logging from datetime import timedelta from ..const.evaporative_humidifier import ( OVER_HUMIDIFY_PROTECTION_MODES, TARGET_HUMIDITY_MODES, HumidifierMode, HumidifierWaterLevel, ) _LOGGER = logging.getLogger(__name__) # mfr_data: 943cc68d3d2e # data: 650000cd802b6300 # data: 650000cd802b6300 # data: 658000c9802b6300 # Low: 658000c5222b6300 # Med: 658000c5432b6300 # High: 658000c5642b6300 def process_wohumidifier( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoHumi services data.""" if data is None: return { "isOn": None, "level": None, "switchMode": True, } return { "isOn": bool(data[1]), "level": data[4], "switchMode": True, } def process_evaporative_humidifier( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoHumi services data.""" if mfr_data is None: return { "isOn": None, "mode": None, "target_humidity": None, "child_lock": None, "over_humidify_protection": None, "tank_removed": None, "tilted_alert": None, "filter_missing": None, "humidity": None, "temperature": None, "filter_run_time": None, "filter_alert": None, "water_level": None, } is_on = bool(mfr_data[7] & 0b10000000) mode = HumidifierMode(mfr_data[7] & 0b00001111) filter_run_time = timedelta(hours=int.from_bytes(mfr_data[12:14], byteorder="big")) has_humidity = bool(mfr_data[9] & 0b10000000) has_temperature = bool(mfr_data[10] & 0b10000000) is_tank_removed = bool(mfr_data[8] & 0b00000100) return { "isOn": is_on, "mode": mode if is_on else None, "target_humidity": (mfr_data[16] & 0b01111111) if is_on and mode in TARGET_HUMIDITY_MODES else None, "child_lock": bool(mfr_data[8] & 0b00100000), "over_humidify_protection": bool(mfr_data[8] & 0b10000000) if is_on and mode in OVER_HUMIDIFY_PROTECTION_MODES else None, "tank_removed": is_tank_removed, "tilted_alert": bool(mfr_data[8] & 0b00000010), "filter_missing": bool(mfr_data[8] & 0b00000001), "humidity": (mfr_data[9] & 0b01111111) if has_humidity else None, "temperature": float(mfr_data[10] & 0b01111111) + float(mfr_data[11] >> 4) / 10 if has_temperature else None, "filter_run_time": filter_run_time, "filter_alert": filter_run_time.days >= 10, "water_level": HumidifierWaterLevel(mfr_data[11] & 0b00000011) if not is_tank_removed else None, } pySwitchbot-0.57.1/switchbot/adv_parsers/keypad.py000066400000000000000000000010441476535110100222730ustar00rootroot00000000000000"""Keypad parser.""" from __future__ import annotations import logging _LOGGER = logging.getLogger(__name__) def process_wokeypad( data: bytes | None, mfr_data: bytes | None, ) -> dict[str, bool | int | None]: """Process woKeypad services data.""" if data is None or mfr_data is None: return {"battery": None, "attempt_state": None} _LOGGER.debug("mfr_data: %s", mfr_data.hex()) if data: _LOGGER.debug("data: %s", data.hex()) return {"battery": data[2] & 0b01111111, "attempt_state": mfr_data[6]} pySwitchbot-0.57.1/switchbot/adv_parsers/leak.py000066400000000000000000000016261476535110100217400ustar00rootroot00000000000000"""Leak detector adv parser.""" def process_leak(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: """Process SwitchBot Water Leak Detector advertisement data.""" if data is None or len(data) < 3 or mfr_data is None or len(mfr_data) < 2: return {} water_leak_detected = None device_tampered = None battery_level = None low_battery = None # Byte 1: Event Flags event_flags = mfr_data[8] water_leak_detected = bool(event_flags & 0b00000001) # Bit 0 device_tampered = bool(event_flags & 0b00000010) # Bit 1 # Byte 2: Battery Info battery_info = mfr_data[7] battery_level = battery_info & 0b01111111 # Bits 0-6 low_battery = bool(battery_info & 0b10000000) # Bit 7 return { "leak": water_leak_detected, "tampered": device_tampered, "battery": battery_level, "low_battery": low_battery, } pySwitchbot-0.57.1/switchbot/adv_parsers/light_strip.py000066400000000000000000000012021476535110100233420ustar00rootroot00000000000000"""Light strip adv parser.""" from __future__ import annotations def process_wostrip( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoStrip services data.""" if mfr_data is None: return {} return { "sequence_number": mfr_data[6], "isOn": bool(mfr_data[7] & 0b10000000), "brightness": mfr_data[7] & 0b01111111, "delay": bool(mfr_data[8] & 0b10000000), "preset": bool(mfr_data[8] & 0b00001000), "color_mode": mfr_data[8] & 0b00000111, "speed": mfr_data[9] & 0b01111111, "loop_index": mfr_data[10] & 0b11111110, } pySwitchbot-0.57.1/switchbot/adv_parsers/lock.py000066400000000000000000000036641476535110100217600ustar00rootroot00000000000000"""Lock parser.""" from __future__ import annotations import logging from ..const.lock import LockStatus _LOGGER = logging.getLogger(__name__) def process_wolock(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]: """Process woLock services data.""" if mfr_data is None: return {} _LOGGER.debug("mfr_data: %s", mfr_data.hex()) if data: _LOGGER.debug("data: %s", data.hex()) return { "battery": data[2] & 0b01111111 if data else None, "calibration": bool(mfr_data[7] & 0b10000000), "status": LockStatus((mfr_data[7] & 0b01110000) >> 4), "update_from_secondary_lock": bool(mfr_data[7] & 0b00001000), "door_open": bool(mfr_data[7] & 0b00000100), "double_lock_mode": bool(mfr_data[8] & 0b10000000), "unclosed_alarm": bool(mfr_data[8] & 0b00100000), "unlocked_alarm": bool(mfr_data[8] & 0b00010000), "auto_lock_paused": bool(mfr_data[8] & 0b00000010), "night_latch": bool(mfr_data[9] & 0b00000001) if len(mfr_data) > 9 else False, } def process_wolock_pro( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: _LOGGER.debug("mfr_data: %s", mfr_data.hex()) if data: _LOGGER.debug("data: %s", data.hex()) res = { "battery": data[2] & 0b01111111 if data else None, "calibration": bool(mfr_data[7] & 0b10000000), "status": LockStatus((mfr_data[7] & 0b00111000) >> 3), "door_open": bool(mfr_data[8] & 0b01100000), # Double lock mode is not supported on Lock Pro "update_from_secondary_lock": False, "double_lock_mode": False, "unclosed_alarm": bool(mfr_data[11] & 0b10000000), "unlocked_alarm": bool(mfr_data[11] & 0b01000000), "auto_lock_paused": bool(mfr_data[8] & 0b100000), # Looks like night latch bit is not anymore in ADV "night_latch": False, } _LOGGER.debug(res) return res pySwitchbot-0.57.1/switchbot/adv_parsers/meter.py000066400000000000000000000031411476535110100221320ustar00rootroot00000000000000"""Meter parser.""" from __future__ import annotations import struct from typing import Any CO2_UNPACK = struct.Struct(">H").unpack_from def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: """Process woSensorTH/Temp sensor services data.""" temp_data: bytes | None = None battery: bytes | None = None if mfr_data: temp_data = mfr_data[8:11] if data: if not temp_data: temp_data = data[3:6] battery = data[2] & 0b01111111 if not temp_data: return {} _temp_sign = 1 if temp_data[1] & 0b10000000 else -1 _temp_c = _temp_sign * ( (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10) ) _temp_f = (_temp_c * 9 / 5) + 32 _temp_f = (_temp_f * 10) / 10 humidity = temp_data[2] & 0b01111111 if _temp_c == 0 and humidity == 0 and battery == 0: return {} _wosensorth_data = { # Data should be flat, but we keep the original structure for now "temp": {"c": _temp_c, "f": _temp_f}, "temperature": _temp_c, "fahrenheit": bool(temp_data[2] & 0b10000000), "humidity": humidity, "battery": battery, } return _wosensorth_data def process_wosensorth_c(data: bytes | None, mfr_data: bytes | None) -> dict[str, Any]: """Process woSensorTH/Temp sensor services data with CO2.""" _wosensorth_data = process_wosensorth(data, mfr_data) if _wosensorth_data and mfr_data and len(mfr_data) >= 15: co2_data = mfr_data[13:15] _wosensorth_data["co2"] = CO2_UNPACK(co2_data)[0] return _wosensorth_data pySwitchbot-0.57.1/switchbot/adv_parsers/motion.py000066400000000000000000000023011476535110100223200ustar00rootroot00000000000000"""Motion sensor parser.""" from __future__ import annotations def process_wopresence( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoPresence Sensor services data.""" if data is None and mfr_data is None: return {} tested = None battery = None led = None iot = None sense_distance = None light_intensity = None is_light = None if data: tested = bool(data[1] & 0b10000000) motion_detected = bool(data[1] & 0b01000000) battery = data[2] & 0b01111111 led = (data[5] & 0b00100000) >> 5 iot = (data[5] & 0b00010000) >> 4 sense_distance = (data[5] & 0b00001100) >> 2 light_intensity = data[5] & 0b00000011 is_light = bool(data[5] & 0b00000010) if mfr_data and len(mfr_data) >= 8: motion_detected = bool(mfr_data[7] & 0b01000000) is_light = bool(mfr_data[7] & 0b00100000) return { "tested": tested, "motion_detected": motion_detected, "battery": battery, "led": led, "iot": iot, "sense_distance": sense_distance, "light_intensity": light_intensity, "is_light": is_light, } pySwitchbot-0.57.1/switchbot/adv_parsers/plug.py000066400000000000000000000007071476535110100217720ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations def process_woplugmini( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process plug mini.""" if mfr_data is None: return {} return { "switchMode": True, "isOn": mfr_data[7] == 0x80, "wifi_rssi": -mfr_data[9], "power": (((mfr_data[10] << 8) + mfr_data[11]) & 0x7FFF) / 10, # W } pySwitchbot-0.57.1/switchbot/adv_parsers/relay_switch.py000066400000000000000000000015651476535110100235230ustar00rootroot00000000000000"""Relay Switch adv parser.""" from __future__ import annotations def process_worelay_switch_1pm( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoStrip services data.""" if mfr_data is None: return {} return { "switchMode": True, # for compatibility, useless "sequence_number": mfr_data[6], "isOn": bool(mfr_data[7] & 0b10000000), "power": ((mfr_data[10] << 8) + mfr_data[11]) / 10, "voltage": 0, "current": 0, } def process_worelay_switch_1( data: bytes | None, mfr_data: bytes | None ) -> dict[str, bool | int]: """Process WoStrip services data.""" if mfr_data is None: return {} return { "switchMode": True, # for compatibility, useless "sequence_number": mfr_data[6], "isOn": bool(mfr_data[7] & 0b10000000), } pySwitchbot-0.57.1/switchbot/adv_parsers/remote.py000066400000000000000000000006611476535110100223150ustar00rootroot00000000000000"""Remote adv parser.""" from __future__ import annotations import logging _LOGGER = logging.getLogger(__name__) def process_woremote( data: bytes | None, mfr_data: bytes | None ) -> dict[str, int | None]: """Process WoRemote adv data.""" if data is None: return { "battery": None, } _LOGGER.debug("data: %s", data.hex()) return { "battery": data[2] & 0b01111111, } pySwitchbot-0.57.1/switchbot/api_config.py000066400000000000000000000004071476535110100206050ustar00rootroot00000000000000# Those values have been obtained from the following files in SwitchBot Android app # That's how you can verify them yourself # /assets/switchbot_config.json SWITCHBOT_APP_API_BASE_URL = "api.switchbot.net" SWITCHBOT_APP_CLIENT_ID = "5nnwmhmsa9xxskm14hd85lm9bm" pySwitchbot-0.57.1/switchbot/const/000077500000000000000000000000001476535110100172625ustar00rootroot00000000000000pySwitchbot-0.57.1/switchbot/const/__init__.py000066400000000000000000000032561476535110100214010ustar00rootroot00000000000000"""Switchbot Device Consts Library.""" from __future__ import annotations from ..enum import StrEnum # Preserve old LockStatus export for backwards compatibility from .lock import LockStatus as LockStatus DEFAULT_RETRY_COUNT = 3 DEFAULT_RETRY_TIMEOUT = 1 DEFAULT_SCAN_TIMEOUT = 5 class SwitchbotApiError(RuntimeError): """ Raised when API call fails. This exception inherits from RuntimeError to avoid breaking existing code but will be changed to Exception in a future release. """ class SwitchbotAuthenticationError(RuntimeError): """ Raised when authentication fails. This exception inherits from RuntimeError to avoid breaking existing code but will be changed to Exception in a future release. """ class SwitchbotAccountConnectionError(RuntimeError): """ Raised when connection to Switchbot account fails. This exception inherits from RuntimeError to avoid breaking existing code but will be changed to Exception in a future release. """ class SwitchbotModel(StrEnum): BOT = "WoHand" CURTAIN = "WoCurtain" HUMIDIFIER = "WoHumi" PLUG_MINI = "WoPlug" CONTACT_SENSOR = "WoContact" LIGHT_STRIP = "WoStrip" METER = "WoSensorTH" METER_PRO = "WoTHP" METER_PRO_C = "WoTHPc" IO_METER = "WoIOSensorTH" MOTION_SENSOR = "WoPresence" COLOR_BULB = "WoBulb" CEILING_LIGHT = "WoCeiling" LOCK = "WoLock" LOCK_PRO = "WoLockPro" BLIND_TILT = "WoBlindTilt" HUB2 = "WoHub2" LEAK = "Leak Detector" KEYPAD = "WoKeypad" RELAY_SWITCH_1PM = "Relay Switch 1PM" RELAY_SWITCH_1 = "Relay Switch 1" REMOTE = "WoRemote" EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier" pySwitchbot-0.57.1/switchbot/const/evaporative_humidifier.py000066400000000000000000000010311476535110100243610ustar00rootroot00000000000000from __future__ import annotations from enum import Enum class HumidifierMode(Enum): HIGH = 1 MEDIUM = 2 LOW = 3 QUIET = 4 TARGET_HUMIDITY = 5 SLEEP = 6 AUTO = 7 DRYING_FILTER = 8 class HumidifierWaterLevel(Enum): EMPTY = 0 LOW = 1 MEDIUM = 2 HIGH = 3 OVER_HUMIDIFY_PROTECTION_MODES = { HumidifierMode.QUIET, HumidifierMode.LOW, HumidifierMode.MEDIUM, HumidifierMode.HIGH, } TARGET_HUMIDITY_MODES = { HumidifierMode.SLEEP, HumidifierMode.TARGET_HUMIDITY, } pySwitchbot-0.57.1/switchbot/const/lock.py000066400000000000000000000004471476535110100205710ustar00rootroot00000000000000from __future__ import annotations from enum import Enum class LockStatus(Enum): LOCKED = 0 UNLOCKED = 1 LOCKING = 2 UNLOCKING = 3 LOCKING_STOP = 4 # LOCKING_BLOCKED UNLOCKING_STOP = 5 # UNLOCKING_BLOCKED NOT_FULLY_LOCKED = 6 # LATCH_LOCKED - Only EU lock type pySwitchbot-0.57.1/switchbot/devices/000077500000000000000000000000001476535110100175565ustar00rootroot00000000000000pySwitchbot-0.57.1/switchbot/devices/__init__.py000066400000000000000000000000401476535110100216610ustar00rootroot00000000000000"""Switchbot Device Library.""" pySwitchbot-0.57.1/switchbot/devices/base_cover.py000066400000000000000000000112461476535110100222440ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import logging from abc import abstractmethod from typing import Any from .device import REQ_HEADER, SwitchbotDevice, update_after_operation # Cover keys COVER_COMMAND = "4501" # For second element of open and close arrs we should add two bytes i.e. ff00 # First byte [ff] stands for speed (00 or ff - normal, 01 - slow) * # * Only for curtains 3. For other models use ff # Second byte [00] is a command (00 - open, 64 - close) POSITION_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}0101", f"{REQ_HEADER}{COVER_COMMAND}05", # +speed ] # +actual_position STOP_KEYS = [f"{REQ_HEADER}{COVER_COMMAND}0001", f"{REQ_HEADER}{COVER_COMMAND}00ff"] COVER_EXT_SUM_KEY = f"{REQ_HEADER}460401" COVER_EXT_ADV_KEY = f"{REQ_HEADER}460402" _LOGGER = logging.getLogger(__name__) class SwitchbotBaseCover(SwitchbotDevice): """Representation of a Switchbot Cover devices for both curtains and tilt blinds.""" def __init__(self, reverse: bool, *args: Any, **kwargs: Any) -> None: """Switchbot Cover device constructor.""" super().__init__(*args, **kwargs) self._reverse = reverse self._settings: dict[str, Any] = {} self.ext_info_sum: dict[str, Any] = {} self.ext_info_adv: dict[str, Any] = {} self._is_opening: bool = False self._is_closing: bool = False async def _send_multiple_commands(self, keys: list[str]) -> bool: """ Send multiple commands to device. Since we current have no way to tell which command the device needs we send both. """ final_result = False for key in keys: result = await self._send_command(key) final_result |= self._check_command_result(result, 0, {1}) return final_result @update_after_operation async def stop(self) -> bool: """Send stop command to device.""" return await self._send_multiple_commands(STOP_KEYS) @update_after_operation async def set_position(self, position: int, speed: int = 255) -> bool: """Send position command (0-100) to device. Speed 255 - normal, 1 - slow""" position = (100 - position) if self._reverse else position return await self._send_multiple_commands( [ f"{POSITION_KEYS[0]}{position:02X}", f"{POSITION_KEYS[1]}{speed:02X}{position:02X}", ] ) @abstractmethod def get_position(self) -> Any: """Return current device position.""" @abstractmethod async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" @abstractmethod async def get_extended_info_summary(self) -> dict[str, Any] | None: """Get extended info for all devices in chain.""" async def get_extended_info_adv(self) -> dict[str, Any] | None: """Get advance page info for device chain.""" _data = await self._send_command(key=COVER_EXT_ADV_KEY) if not _data: _LOGGER.error("%s: Unsuccessful, no result from device", self.name) return None if _data in (b"\x07", b"\x00"): _LOGGER.error("%s: Unsuccessful, please try again", self.name) return None _state_of_charge = [ "not_charging", "charging_by_adapter", "charging_by_solar", "fully_charged", "solar_not_charging", "charging_error", ] self.ext_info_adv["device0"] = { "battery": _data[1], "firmware": _data[2] / 10.0, "stateOfCharge": _state_of_charge[_data[3]], } # If grouped curtain device present. if _data[4]: self.ext_info_adv["device1"] = { "battery": _data[4], "firmware": _data[5] / 10.0, "stateOfCharge": _state_of_charge[_data[6]], } return self.ext_info_adv def get_light_level(self) -> Any: """Return cached light level.""" # To get actual light level call update() first. return self._get_adv_value("lightLevel") def is_reversed(self) -> bool: """Return True if curtain position is opposite from SB data.""" return self._reverse def is_calibrated(self) -> Any: """Return True curtain is calibrated.""" # To get actual light level call update() first. return self._get_adv_value("calibration") def is_opening(self) -> bool: """Return True if the curtain is opening.""" return self._is_opening def is_closing(self) -> bool: """Return True if the curtain is closing.""" return self._is_closing pySwitchbot-0.57.1/switchbot/devices/base_light.py000066400000000000000000000063501476535110100222350ustar00rootroot00000000000000from __future__ import annotations import logging import time from abc import abstractmethod from typing import Any from ..helpers import create_background_task from ..models import SwitchBotAdvertisement from .device import ColorMode, SwitchbotDevice _LOGGER = logging.getLogger(__name__) class SwitchbotBaseLight(SwitchbotDevice): """Representation of a Switchbot light.""" def __init__(self, *args: Any, **kwargs: Any) -> None: """Switchbot bulb constructor.""" super().__init__(*args, **kwargs) self._state: dict[str, Any] = {} @property def on(self) -> bool | None: """Return if bulb is on.""" return self.is_on() @property def rgb(self) -> tuple[int, int, int] | None: """Return the current rgb value.""" if "r" not in self._state or "g" not in self._state or "b" not in self._state: return None return self._state["r"], self._state["g"], self._state["b"] @property def color_temp(self) -> int | None: """Return the current color temp value.""" return self._state.get("cw") or self.min_temp @property def brightness(self) -> int | None: """Return the current brightness value.""" return self._get_adv_value("brightness") or 0 @property def color_mode(self) -> ColorMode: """Return the current color mode.""" return ColorMode(self._get_adv_value("color_mode") or 0) @property def min_temp(self) -> int: """Return minimum color temp.""" return 2700 @property def max_temp(self) -> int: """Return maximum color temp.""" return 6500 def is_on(self) -> bool | None: """Return bulb state from cache.""" return self._get_adv_value("isOn") @abstractmethod async def turn_on(self) -> bool: """Turn device on.""" @abstractmethod async def turn_off(self) -> bool: """Turn device off.""" @abstractmethod async def set_brightness(self, brightness: int) -> bool: """Set brightness.""" @abstractmethod async def set_color_temp(self, brightness: int, color_temp: int) -> bool: """Set color temp.""" @abstractmethod async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: """Set rgb.""" def poll_needed(self, last_poll_time: float | None) -> bool: """Return if poll is needed.""" return False async def update(self) -> None: """Update device data.""" self._last_full_update = time.monotonic() class SwitchbotSequenceBaseLight(SwitchbotBaseLight): """Representation of a Switchbot light.""" def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: """Update device data from advertisement.""" current_state = self._get_adv_value("sequence_number") super().update_from_advertisement(advertisement) new_state = self._get_adv_value("sequence_number") _LOGGER.debug( "%s: update advertisement: %s (seq before: %s) (seq after: %s)", self.name, advertisement, current_state, new_state, ) if current_state != new_state: create_background_task(self.update()) pySwitchbot-0.57.1/switchbot/devices/blind_tilt.py000066400000000000000000000132231476535110100222550ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import logging from typing import Any from switchbot.devices.device import ( REQ_HEADER, SwitchbotSequenceDevice, update_after_operation, ) from ..models import SwitchBotAdvertisement from .base_cover import COVER_COMMAND, COVER_EXT_SUM_KEY, SwitchbotBaseCover _LOGGER = logging.getLogger(__name__) OPEN_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}010132", f"{REQ_HEADER}{COVER_COMMAND}05ff32", ] CLOSE_DOWN_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}010100", f"{REQ_HEADER}{COVER_COMMAND}05ff00", ] CLOSE_UP_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}010164", f"{REQ_HEADER}{COVER_COMMAND}05ff64", ] class SwitchbotBlindTilt(SwitchbotBaseCover, SwitchbotSequenceDevice): """Representation of a Switchbot Blind Tilt.""" # The position of the blind is saved returned with 0 = closed down, 50 = open and 100 = closed up. # This is independent of the calibration of the blind. # The parameter 'reverse_mode' reverse these values, # if 'reverse_mode' = True, position = 0 equals closed up # and position = 100 equals closed down. The parameter is default set to False so that # the definition of position is the same as in Home Assistant. # This is opposite to the base class so needs to be overwritten. def __init__(self, *args: Any, **kwargs: Any) -> None: """Switchbot Blind Tilt/woBlindTilt constructor.""" self._reverse: bool = kwargs.pop("reverse_mode", False) super().__init__(self._reverse, *args, **kwargs) def _set_parsed_data( self, advertisement: SwitchBotAdvertisement, data: dict[str, Any] ) -> None: """Set data.""" in_motion = data["inMotion"] previous_tilt = self._get_adv_value("tilt") new_tilt = data["tilt"] self._update_motion_direction(in_motion, previous_tilt, new_tilt) super()._set_parsed_data(advertisement, data) def _update_motion_direction( self, in_motion: bool, previous_tilt: int | None, new_tilt: int ) -> None: """Update opening/closing status based on movement.""" if previous_tilt is None: return if in_motion is False: self._is_closing = self._is_opening = False return if new_tilt != previous_tilt: self._is_opening = new_tilt > previous_tilt self._is_closing = new_tilt < previous_tilt @update_after_operation async def open(self) -> bool: """Send open command.""" self._is_opening = True self._is_closing = False return await self._send_multiple_commands(OPEN_KEYS) @update_after_operation async def close_up(self) -> bool: """Send close up command.""" self._is_opening = False self._is_closing = True return await self._send_multiple_commands(CLOSE_UP_KEYS) @update_after_operation async def close_down(self) -> bool: """Send close down command.""" self._is_opening = False self._is_closing = True return await self._send_multiple_commands(CLOSE_DOWN_KEYS) # The aim of this is to close to the nearest endpoint. # If we're open upwards we close up, if we're open downwards we close down. # If we're in the middle we default to close down as that seems to be the app's preference. @update_after_operation async def close(self) -> bool: """Send close command.""" if self.get_position() > 50: return await self.close_up() else: return await self.close_down() def get_position(self) -> Any: """Return cached tilt (0-100) of Blind Tilt.""" # To get actual tilt call update() first. return self._get_adv_value("tilt") async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None _tilt = max(min(_data[6], 100), 0) _moving = bool(_data[5] & 0b00000011) if _moving: _opening = bool(_data[5] & 0b00000010) _closing = not _opening and bool(_data[5] & 0b00000001) if _opening: _flag = bool(_data[5] & 0b00000001) _up = _flag if self._reverse else not _flag else: _up = _tilt < 50 if self._reverse else _tilt > 50 return { "battery": _data[1], "firmware": _data[2] / 10.0, "light": bool(_data[4] & 0b00100000), "fault": bool(_data[4] & 0b00001000), "solarPanel": bool(_data[5] & 0b00001000), "calibration": bool(_data[5] & 0b00000100), "calibrated": bool(_data[5] & 0b00000100), "inMotion": _moving, "motionDirection": { "opening": _moving and _opening, "closing": _moving and _closing, "up": _moving and _up, "down": _moving and not _up, }, "tilt": (100 - _tilt) if self._reverse else _tilt, "timers": _data[7], } async def get_extended_info_summary(self) -> dict[str, Any] | None: """Get extended info for all devices in chain.""" _data = await self._send_command(key=COVER_EXT_SUM_KEY) if not _data: _LOGGER.error("%s: Unsuccessful, no result from device", self.name) return None if _data in (b"\x07", b"\x00"): _LOGGER.error("%s: Unsuccessful, please try again", self.name) return None self.ext_info_sum["device0"] = { "light": bool(_data[1] & 0b00100000), } return self.ext_info_sum pySwitchbot-0.57.1/switchbot/devices/bot.py000066400000000000000000000100311476535110100207070ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import logging from typing import Any from .device import ( DEVICE_SET_EXTENDED_KEY, DEVICE_SET_MODE_KEY, SwitchbotDeviceOverrideStateDuringConnection, update_after_operation, ) _LOGGER = logging.getLogger(__name__) BOT_COMMAND_HEADER = "5701" # Bot keys PRESS_KEY = f"{BOT_COMMAND_HEADER}00" ON_KEY = f"{BOT_COMMAND_HEADER}01" OFF_KEY = f"{BOT_COMMAND_HEADER}02" DOWN_KEY = f"{BOT_COMMAND_HEADER}03" UP_KEY = f"{BOT_COMMAND_HEADER}04" class Switchbot(SwitchbotDeviceOverrideStateDuringConnection): """Representation of a Switchbot.""" def __init__(self, *args: Any, **kwargs: Any) -> None: """Switchbot Bot/WoHand constructor.""" super().__init__(*args, **kwargs) self._inverse: bool = kwargs.pop("inverse_mode", False) @update_after_operation async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(ON_KEY) ret = self._check_command_result(result, 0, {1, 5}) self._override_state({"isOn": True}) _LOGGER.debug( "%s: Turn on result: %s -> %s", self.name, result.hex() if result else None, self._override_adv_data, ) self._fire_callbacks() return ret @update_after_operation async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(OFF_KEY) ret = self._check_command_result(result, 0, {1, 5}) self._override_state({"isOn": False}) _LOGGER.debug( "%s: Turn off result: %s -> %s", self.name, result.hex() if result else None, self._override_adv_data, ) self._fire_callbacks() return ret @update_after_operation async def hand_up(self) -> bool: """Raise device arm.""" result = await self._send_command(UP_KEY) return self._check_command_result(result, 0, {1, 5}) @update_after_operation async def hand_down(self) -> bool: """Lower device arm.""" result = await self._send_command(DOWN_KEY) return self._check_command_result(result, 0, {1, 5}) @update_after_operation async def press(self) -> bool: """Press command to device.""" result = await self._send_command(PRESS_KEY) return self._check_command_result(result, 0, {1, 5}) @update_after_operation async def set_switch_mode( self, switch_mode: bool = False, strength: int = 100, inverse: bool = False ) -> bool: """Change bot mode.""" mode_key = format(switch_mode, "b") + format(inverse, "b") strength_key = f"{strength:0{2}x}" # to hex with padding to double digit result = await self._send_command(DEVICE_SET_MODE_KEY + strength_key + mode_key) return self._check_command_result(result, 0, {1}) @update_after_operation async def set_long_press(self, duration: int = 0) -> bool: """Set bot long press duration.""" duration_key = f"{duration:0{2}x}" # to hex with padding to double digit result = await self._send_command(DEVICE_SET_EXTENDED_KEY + "08" + duration_key) return self._check_command_result(result, 0, {1}) async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None return { "battery": _data[1], "firmware": _data[2] / 10.0, "strength": _data[3], "timers": _data[8], "switchMode": bool(_data[9] & 16), "inverseDirection": bool(_data[9] & 1), "holdSeconds": _data[10], } def is_on(self) -> bool | None: """Return switch state from cache.""" # To get actual position call update() first. value = self._get_adv_value("isOn") if value is None: return None if self._inverse: return not value return value pySwitchbot-0.57.1/switchbot/devices/bulb.py000066400000000000000000000066101476535110100210570ustar00rootroot00000000000000from __future__ import annotations import logging from .base_light import SwitchbotSequenceBaseLight from .device import REQ_HEADER, ColorMode BULB_COMMAND_HEADER = "4701" BULB_REQUEST = f"{REQ_HEADER}4801" BULB_COMMAND = f"{REQ_HEADER}{BULB_COMMAND_HEADER}" # Bulb keys BULB_ON_KEY = f"{BULB_COMMAND}01" BULB_OFF_KEY = f"{BULB_COMMAND}02" RGB_BRIGHTNESS_KEY = f"{BULB_COMMAND}12" CW_BRIGHTNESS_KEY = f"{BULB_COMMAND}13" BRIGHTNESS_KEY = f"{BULB_COMMAND}14" RGB_KEY = f"{BULB_COMMAND}16" CW_KEY = f"{BULB_COMMAND}17" _LOGGER = logging.getLogger(__name__) class SwitchbotBulb(SwitchbotSequenceBaseLight): """Representation of a Switchbot bulb.""" @property def color_modes(self) -> set[ColorMode]: """Return the supported color modes.""" return {ColorMode.RGB, ColorMode.COLOR_TEMP} async def update(self) -> None: """Update state of device.""" result = await self._send_command(BULB_REQUEST) self._update_state(result) await super().update() async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(BULB_ON_KEY) self._update_state(result) return self._check_command_result(result, 1, {0x80}) async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(BULB_OFF_KEY) self._update_state(result) return self._check_command_result(result, 1, {0x00}) async def set_brightness(self, brightness: int) -> bool: """Set brightness.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}") self._update_state(result) return self._check_command_result(result, 1, {0x80}) async def set_color_temp(self, brightness: int, color_temp: int) -> bool: """Set color temp.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" assert 2700 <= color_temp <= 6500, "Color Temp must be between 0 and 100" result = await self._send_command( f"{CW_BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}" ) self._update_state(result) return self._check_command_result(result, 1, {0x80}) async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: """Set rgb.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" assert 0 <= r <= 255, "r must be between 0 and 255" assert 0 <= g <= 255, "g must be between 0 and 255" assert 0 <= b <= 255, "b must be between 0 and 255" result = await self._send_command( f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}" ) self._update_state(result) return self._check_command_result(result, 1, {0x80}) def _update_state(self, result: bytes | None) -> None: """Update device state.""" if not result or len(result) < 10: return self._state["r"] = result[3] self._state["g"] = result[4] self._state["b"] = result[5] self._state["cw"] = int(result[6:8].hex(), 16) self._override_state( { "isOn": result[1] == 0x80, "color_mode": result[10], } ) _LOGGER.debug("%s: update state: %s = %s", self.name, result.hex(), self._state) self._fire_callbacks() pySwitchbot-0.57.1/switchbot/devices/ceiling_light.py000066400000000000000000000050001476535110100227240ustar00rootroot00000000000000from __future__ import annotations import logging from .base_light import SwitchbotBaseLight from .device import REQ_HEADER, ColorMode CEILING_LIGHT_COMMAND_HEADER = "5401" CEILING_LIGHT_REQUEST = f"{REQ_HEADER}5501" CEILING_LIGHT_COMMAND = f"{REQ_HEADER}{CEILING_LIGHT_COMMAND_HEADER}" CEILING_LIGHT_ON_KEY = f"{CEILING_LIGHT_COMMAND}01FF01FFFF" CEILING_LIGHT_OFF_KEY = f"{CEILING_LIGHT_COMMAND}02FF01FFFF" CW_BRIGHTNESS_KEY = f"{CEILING_LIGHT_COMMAND}010001" BRIGHTNESS_KEY = f"{CEILING_LIGHT_COMMAND}01FF01" _LOGGER = logging.getLogger(__name__) class SwitchbotCeilingLight(SwitchbotBaseLight): """Representation of a Switchbot bulb.""" @property def color_modes(self) -> set[ColorMode]: """Return the supported color modes.""" return {ColorMode.COLOR_TEMP} async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(CEILING_LIGHT_ON_KEY) ret = self._check_command_result(result, 0, {0x01}) self._override_state({"isOn": True}) self._fire_callbacks() return ret async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(CEILING_LIGHT_OFF_KEY) ret = self._check_command_result(result, 0, {0x01}) self._override_state({"isOn": False}) self._fire_callbacks() return ret async def set_brightness(self, brightness: int) -> bool: """Set brightness.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}0FA1") ret = self._check_command_result(result, 0, {0x01}) self._override_state({"brightness": brightness, "isOn": True}) self._fire_callbacks() return ret async def set_color_temp(self, brightness: int, color_temp: int) -> bool: """Set color temp.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" assert 2700 <= color_temp <= 6500, "Color Temp must be between 0 and 100" result = await self._send_command( f"{CW_BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}" ) ret = self._check_command_result(result, 0, {0x01}) self._state["cw"] = color_temp self._override_state({"brightness": brightness, "isOn": True}) self._fire_callbacks() return ret async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: """Set rgb.""" # Not supported on this device pySwitchbot-0.57.1/switchbot/devices/contact.py000066400000000000000000000000431476535110100215600ustar00rootroot00000000000000from __future__ import annotations pySwitchbot-0.57.1/switchbot/devices/curtain.py000066400000000000000000000156561476535110100216120ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import logging from typing import Any from ..models import SwitchBotAdvertisement from .base_cover import COVER_COMMAND, COVER_EXT_SUM_KEY, SwitchbotBaseCover from .device import REQ_HEADER, update_after_operation # For second element of open and close arrs we should add two bytes i.e. ff00 # First byte [ff] stands for speed (00 or ff - normal, 01 - slow) * # * Only for curtains 3. For other models use ff # Second byte [00] is a command (00 - open, 64 - close) OPEN_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}010100", f"{REQ_HEADER}{COVER_COMMAND}05", # +speed + "00" ] CLOSE_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}010164", f"{REQ_HEADER}{COVER_COMMAND}05", # +speed + "64" ] POSITION_KEYS = [ f"{REQ_HEADER}{COVER_COMMAND}0101", f"{REQ_HEADER}{COVER_COMMAND}05", # +speed ] # +actual_position STOP_KEYS = [f"{REQ_HEADER}{COVER_COMMAND}0001", f"{REQ_HEADER}{COVER_COMMAND}00ff"] CURTAIN_EXT_CHAIN_INFO_KEY = f"{REQ_HEADER}468101" _LOGGER = logging.getLogger(__name__) class SwitchbotCurtain(SwitchbotBaseCover): """Representation of a Switchbot Curtain.""" def __init__(self, *args: Any, **kwargs: Any) -> None: """Switchbot Curtain/WoCurtain constructor.""" # The position of the curtain is saved returned with 0 = open and 100 = closed. # This is independent of the calibration of the curtain bot (Open left to right/ # Open right to left/Open from the middle). # The parameter 'reverse_mode' reverse these values, # if 'reverse_mode' = True, position = 0 equals close # and position = 100 equals open. The parameter is default set to True so that # the definition of position is the same as in Home Assistant. self._reverse: bool = kwargs.pop("reverse_mode", True) super().__init__(self._reverse, *args, **kwargs) self._settings: dict[str, Any] = {} self.ext_info_sum: dict[str, Any] = {} self.ext_info_adv: dict[str, Any] = {} def _set_parsed_data( self, advertisement: SwitchBotAdvertisement, data: dict[str, Any] ) -> None: """Set data.""" in_motion = data["inMotion"] previous_position = self._get_adv_value("position") new_position = data["position"] self._update_motion_direction(in_motion, previous_position, new_position) super()._set_parsed_data(advertisement, data) @update_after_operation async def open(self, speed: int = 255) -> bool: """Send open command. Speed 255 - normal, 1 - slow""" self._is_opening = True self._is_closing = False return await self._send_multiple_commands( [OPEN_KEYS[0], f"{OPEN_KEYS[1]}{speed:02X}00"] ) @update_after_operation async def close(self, speed: int = 255) -> bool: """Send close command. Speed 255 - normal, 1 - slow""" self._is_closing = True self._is_opening = False return await self._send_multiple_commands( [CLOSE_KEYS[0], f"{CLOSE_KEYS[1]}{speed:02X}64"] ) @update_after_operation async def stop(self) -> bool: """Send stop command to device.""" self._is_opening = self._is_closing = False return await super().stop() @update_after_operation async def set_position(self, position: int, speed: int = 255) -> bool: """Send position command (0-100) to device. Speed 255 - normal, 1 - slow""" direction_adjusted_position = (100 - position) if self._reverse else position self._update_motion_direction( True, self._get_adv_value("position"), direction_adjusted_position ) return await super().set_position(position, speed) def get_position(self) -> Any: """Return cached position (0-100) of Curtain.""" # To get actual position call update() first. return self._get_adv_value("position") async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None _position = max(min(_data[6], 100), 0) _direction_adjusted_position = (100 - _position) if self._reverse else _position _previous_position = self._get_adv_value("position") _in_motion = bool(_data[5] & 0b01000011) self._update_motion_direction( _in_motion, _previous_position, _direction_adjusted_position ) return { "battery": _data[1], "firmware": _data[2] / 10.0, "chainLength": _data[3], "openDirection": ( "right_to_left" if _data[4] & 0b10000000 == 128 else "left_to_right" ), "touchToOpen": bool(_data[4] & 0b01000000), "light": bool(_data[4] & 0b00100000), "fault": bool(_data[4] & 0b00001000), "solarPanel": bool(_data[5] & 0b00001000), "calibration": bool(_data[5] & 0b00000100), "calibrated": bool(_data[5] & 0b00000100), "inMotion": _in_motion, "position": _direction_adjusted_position, "timers": _data[7], } def _update_motion_direction( self, in_motion: bool, previous_position: int | None, new_position: int ) -> None: """Update opening/closing status based on movement.""" if previous_position is None: return if in_motion is False: self._is_closing = self._is_opening = False return if new_position != previous_position: self._is_opening = new_position > previous_position self._is_closing = new_position < previous_position async def get_extended_info_summary(self) -> dict[str, Any] | None: """Get extended info for all devices in chain.""" _data = await self._send_command(key=COVER_EXT_SUM_KEY) if not _data: _LOGGER.error("%s: Unsuccessful, no result from device", self.name) return None if _data in (b"\x07", b"\x00"): _LOGGER.error("%s: Unsuccessful, please try again", self.name) return None self.ext_info_sum["device0"] = { "openDirectionDefault": not bool(_data[1] & 0b10000000), "touchToOpen": bool(_data[1] & 0b01000000), "light": bool(_data[1] & 0b00100000), "openDirection": ( "left_to_right" if _data[1] & 0b00010000 else "right_to_left" ), } # if grouped curtain device present. if _data[2] != 0: self.ext_info_sum["device1"] = { "openDirectionDefault": not bool(_data[2] & 0b10000000), "touchToOpen": bool(_data[2] & 0b01000000), "light": bool(_data[2] & 0b00100000), "openDirection": ( "left_to_right" if _data[2] & 0b00010000 else "right_to_left" ), } return self.ext_info_sum pySwitchbot-0.57.1/switchbot/devices/device.py000066400000000000000000001003321476535110100213660ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import asyncio import binascii import logging import time from collections.abc import Callable from dataclasses import replace from enum import Enum from typing import Any, TypeVar, cast from uuid import UUID import aiohttp from bleak.backends.device import BLEDevice from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection from bleak.exc import BleakDBusError from bleak_retry_connector import ( BLEAK_RETRY_EXCEPTIONS, BleakClientWithServiceCache, BleakNotFoundError, ble_device_has_changed, establish_connection, ) from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID from ..const import ( DEFAULT_RETRY_COUNT, DEFAULT_SCAN_TIMEOUT, SwitchbotAccountConnectionError, SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotModel, ) from ..discovery import GetSwitchbotDevices from ..helpers import create_background_task from ..models import SwitchBotAdvertisement _LOGGER = logging.getLogger(__name__) REQ_HEADER = "570f" # Keys common to all device types DEVICE_GET_BASIC_SETTINGS_KEY = "5702" DEVICE_SET_MODE_KEY = "5703" DEVICE_SET_EXTENDED_KEY = REQ_HEADER COMMAND_GET_CK_IV = f"{REQ_HEADER}2103" # Base key when encryption is set KEY_PASSWORD_PREFIX = "571" DBUS_ERROR_BACKOFF_TIME = 0.25 # How long to hold the connection # to wait for additional commands for # disconnecting the device. DISCONNECT_DELAY = 8.5 class ColorMode(Enum): OFF = 0 COLOR_TEMP = 1 RGB = 2 EFFECT = 3 # If the scanner is in passive mode, we # need to poll the device to get the # battery and a few rarely updating # values. PASSIVE_POLL_INTERVAL = 60 * 60 * 24 class CharacteristicMissingError(Exception): """Raised when a characteristic is missing.""" class SwitchbotOperationError(Exception): """Raised when an operation fails.""" def _sb_uuid(comms_type: str = "service") -> UUID | str: """Return Switchbot UUID.""" _uuid = {"tx": "002", "rx": "003", "service": "d00"} if comms_type in _uuid: return UUID(f"cba20{_uuid[comms_type]}-224d-11e6-9fb8-0002a5d5c51b") return "Incorrect type, choose between: tx, rx or service" READ_CHAR_UUID = _sb_uuid(comms_type="rx") WRITE_CHAR_UUID = _sb_uuid(comms_type="tx") WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) def update_after_operation(func: WrapFuncType) -> WrapFuncType: """Define a wrapper to update after an operation.""" async def _async_update_after_operation_wrap( self: SwitchbotBaseDevice, *args: Any, **kwargs: Any ) -> None: ret = await func(self, *args, **kwargs) await self.update() return ret return cast(WrapFuncType, _async_update_after_operation_wrap) def _merge_data(old_data: dict[str, Any], new_data: dict[str, Any]) -> dict[str, Any]: """Merge data but only add None keys if they are missing.""" merged = old_data.copy() for key, value in new_data.items(): if value is not None or key not in old_data: merged[key] = value return merged def _handle_timeout(fut: asyncio.Future[None]) -> None: """Handle a timeout.""" if not fut.done(): fut.set_exception(asyncio.TimeoutError) class SwitchbotBaseDevice: """Base Representation of a Switchbot Device.""" def __init__( self, device: BLEDevice, password: str | None = None, interface: int = 0, **kwargs: Any, ) -> None: """Switchbot base class constructor.""" self._interface = f"hci{interface}" self._device = device self._sb_adv_data: SwitchBotAdvertisement | None = None self._override_adv_data: dict[str, Any] | None = None self._scan_timeout: int = kwargs.pop("scan_timeout", DEFAULT_SCAN_TIMEOUT) self._retry_count: int = kwargs.pop("retry_count", DEFAULT_RETRY_COUNT) self._connect_lock = asyncio.Lock() self._operation_lock = asyncio.Lock() if password is None or password == "": self._password_encoded = None else: self._password_encoded = "%08x" % ( binascii.crc32(password.encode("ascii")) & 0xFFFFFFFF ) self._client: BleakClientWithServiceCache | None = None self._read_char: BleakGATTCharacteristic | None = None self._write_char: BleakGATTCharacteristic | None = None self._disconnect_timer: asyncio.TimerHandle | None = None self._expected_disconnect = False self.loop = asyncio.get_event_loop() self._callbacks: list[Callable[[], None]] = [] self._notify_future: asyncio.Future[bytearray] | None = None self._last_full_update: float = -PASSIVE_POLL_INTERVAL self._timed_disconnect_task: asyncio.Task[None] | None = None @classmethod async def api_request( cls, session: aiohttp.ClientSession, subdomain: str, path: str, data: dict | None = None, headers: dict | None = None, ) -> dict: url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}" async with session.post( url, json=data, headers=headers, timeout=aiohttp.ClientTimeout(total=10), ) as result: if result.status > 299: raise SwitchbotApiError( f"Unexpected status code returned by SwitchBot API: {result.status}" ) response = await result.json() if response["statusCode"] != 100: raise SwitchbotApiError( f"{response['message']}, status code: {response['statusCode']}" ) return response["body"] def advertisement_changed(self, advertisement: SwitchBotAdvertisement) -> bool: """Check if the advertisement has changed.""" return bool( not self._sb_adv_data or ble_device_has_changed(self._sb_adv_data.device, advertisement.device) or advertisement.data != self._sb_adv_data.data ) def _commandkey(self, key: str) -> str: """Add password to key if set.""" if self._password_encoded is None: return key key_action = key[3] key_suffix = key[4:] return KEY_PASSWORD_PREFIX + key_action + self._password_encoded + key_suffix async def _send_command(self, key: str, retry: int | None = None) -> bytes | None: """Send command to device and read response.""" if retry is None: retry = self._retry_count command = bytearray.fromhex(self._commandkey(key)) _LOGGER.debug("%s: Scheduling command %s", self.name, command.hex()) max_attempts = retry + 1 if self._operation_lock.locked(): _LOGGER.debug( "%s: Operation already in progress, waiting for it to complete; RSSI: %s", self.name, self.rssi, ) async with self._operation_lock: for attempt in range(max_attempts): try: return await self._send_command_locked(key, command) except BleakNotFoundError: _LOGGER.error( "%s: device not found, no longer in range, or poor RSSI: %s", self.name, self.rssi, exc_info=True, ) raise except CharacteristicMissingError as ex: if attempt == retry: _LOGGER.error( "%s: characteristic missing: %s; Stopping trying; RSSI: %s", self.name, ex, self.rssi, exc_info=True, ) raise _LOGGER.debug( "%s: characteristic missing: %s; RSSI: %s", self.name, ex, self.rssi, exc_info=True, ) except BLEAK_RETRY_EXCEPTIONS: if attempt == retry: _LOGGER.error( "%s: communication failed; Stopping trying; RSSI: %s", self.name, self.rssi, exc_info=True, ) raise _LOGGER.debug( "%s: communication failed with:", self.name, exc_info=True ) raise RuntimeError("Unreachable") @property def name(self) -> str: """Return device name.""" return f"{self._device.name} ({self._device.address})" @property def data(self) -> dict[str, Any]: """Return device data.""" if self._sb_adv_data: return self._sb_adv_data.data return {} @property def parsed_data(self) -> dict[str, Any]: """Return parsed device data.""" return self.data.get("data") or {} @property def rssi(self) -> int: """Return RSSI of device.""" if self._sb_adv_data: return self._sb_adv_data.rssi return self._device.rssi async def _ensure_connected(self): """Ensure connection to device is established.""" if self._connect_lock.locked(): _LOGGER.debug( "%s: Connection already in progress, waiting for it to complete; RSSI: %s", self.name, self.rssi, ) if self._client and self._client.is_connected: _LOGGER.debug( "%s: Already connected before obtaining lock, resetting timer; RSSI: %s", self.name, self.rssi, ) self._reset_disconnect_timer() return async with self._connect_lock: # Check again while holding the lock if self._client and self._client.is_connected: _LOGGER.debug( "%s: Already connected after obtaining lock, resetting timer; RSSI: %s", self.name, self.rssi, ) self._reset_disconnect_timer() return _LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi) client: BleakClientWithServiceCache = await establish_connection( BleakClientWithServiceCache, self._device, self.name, self._disconnected, use_services_cache=True, ble_device_callback=lambda: self._device, ) _LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi) self._client = client try: self._resolve_characteristics(client.services) except CharacteristicMissingError as ex: _LOGGER.debug( "%s: characteristic missing, clearing cache: %s; RSSI: %s", self.name, ex, self.rssi, exc_info=True, ) await client.clear_cache() self._cancel_disconnect_timer() await self._execute_disconnect_with_lock() raise _LOGGER.debug( "%s: Starting notify and disconnect timer; RSSI: %s", self.name, self.rssi, ) self._reset_disconnect_timer() await self._start_notify() def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> None: """Resolve characteristics.""" self._read_char = services.get_characteristic(READ_CHAR_UUID) if not self._read_char: raise CharacteristicMissingError(READ_CHAR_UUID) self._write_char = services.get_characteristic(WRITE_CHAR_UUID) if not self._write_char: raise CharacteristicMissingError(WRITE_CHAR_UUID) def _reset_disconnect_timer(self): """Reset disconnect timer.""" self._cancel_disconnect_timer() self._expected_disconnect = False self._disconnect_timer = self.loop.call_later( DISCONNECT_DELAY, self._disconnect_from_timer ) def _disconnected(self, client: BleakClientWithServiceCache) -> None: """Disconnected callback.""" if self._expected_disconnect: _LOGGER.debug( "%s: Disconnected from device; RSSI: %s", self.name, self.rssi ) return _LOGGER.warning( "%s: Device unexpectedly disconnected; RSSI: %s", self.name, self.rssi, ) self._cancel_disconnect_timer() def _disconnect_from_timer(self): """Disconnect from device.""" if self._operation_lock.locked() and self._client.is_connected: _LOGGER.debug( "%s: Operation in progress, resetting disconnect timer; RSSI: %s", self.name, self.rssi, ) self._reset_disconnect_timer() return self._cancel_disconnect_timer() self._timed_disconnect_task = asyncio.create_task( self._execute_timed_disconnect() ) def _cancel_disconnect_timer(self): """Cancel disconnect timer.""" if self._disconnect_timer: self._disconnect_timer.cancel() self._disconnect_timer = None async def _execute_forced_disconnect(self) -> None: """Execute forced disconnection.""" self._cancel_disconnect_timer() _LOGGER.debug( "%s: Executing forced disconnect", self.name, ) await self._execute_disconnect() async def _execute_timed_disconnect(self) -> None: """Execute timed disconnection.""" _LOGGER.debug( "%s: Executing timed disconnect after timeout of %s", self.name, DISCONNECT_DELAY, ) await self._execute_disconnect() async def _execute_disconnect(self) -> None: """Execute disconnection.""" _LOGGER.debug("%s: Executing disconnect", self.name) async with self._connect_lock: await self._execute_disconnect_with_lock() async def _execute_disconnect_with_lock(self) -> None: """Execute disconnection while holding the lock.""" assert self._connect_lock.locked(), "Lock not held" _LOGGER.debug("%s: Executing disconnect with lock", self.name) if self._disconnect_timer: # If the timer was reset, don't disconnect _LOGGER.debug("%s: Skipping disconnect as timer reset", self.name) return client = self._client self._expected_disconnect = True self._client = None self._read_char = None self._write_char = None if not client: _LOGGER.debug("%s: Already disconnected", self.name) return _LOGGER.debug("%s: Disconnecting", self.name) try: await client.disconnect() except BLEAK_RETRY_EXCEPTIONS as ex: _LOGGER.warning( "%s: Error disconnecting: %s; RSSI: %s", self.name, ex, self.rssi, ) else: _LOGGER.debug("%s: Disconnect completed successfully", self.name) async def _send_command_locked(self, key: str, command: bytes) -> bytes: """Send command to device and read response.""" await self._ensure_connected() try: return await self._execute_command_locked(key, command) except BleakDBusError as ex: # Disconnect so we can reset state and try again await asyncio.sleep(DBUS_ERROR_BACKOFF_TIME) _LOGGER.debug( "%s: RSSI: %s; Backing off %ss; Disconnecting due to error: %s", self.name, self.rssi, DBUS_ERROR_BACKOFF_TIME, ex, ) await self._execute_forced_disconnect() raise except BLEAK_RETRY_EXCEPTIONS as ex: # Disconnect so we can reset state and try again _LOGGER.debug( "%s: RSSI: %s; Disconnecting due to error: %s", self.name, self.rssi, ex ) await self._execute_forced_disconnect() raise def _notification_handler(self, _sender: int, data: bytearray) -> None: """Handle notification responses.""" if self._notify_future and not self._notify_future.done(): self._notify_future.set_result(data) return _LOGGER.debug("%s: Received unsolicited notification: %s", self.name, data) async def _start_notify(self) -> None: """Start notification.""" _LOGGER.debug("%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi) await self._client.start_notify(self._read_char, self._notification_handler) async def _execute_command_locked(self, key: str, command: bytes) -> bytes: """Execute command and read response.""" assert self._client is not None assert self._read_char is not None assert self._write_char is not None self._notify_future = self.loop.create_future() client = self._client _LOGGER.debug("%s: Sending command: %s", self.name, key) await client.write_gatt_char(self._write_char, command, False) timeout = 5 timeout_handle = self.loop.call_at( self.loop.time() + timeout, _handle_timeout, self._notify_future ) timeout_expired = False try: notify_msg = await self._notify_future except TimeoutError: timeout_expired = True raise finally: if not timeout_expired: timeout_handle.cancel() self._notify_future = None _LOGGER.debug("%s: Notification received: %s", self.name, notify_msg.hex()) if notify_msg == b"\x07": _LOGGER.error("Password required") elif notify_msg == b"\t": _LOGGER.error("Password incorrect") return notify_msg def get_address(self) -> str: """Return address of device.""" return self._device.address def _override_state(self, state: dict[str, Any]) -> None: """Override device state.""" if self._override_adv_data is None: self._override_adv_data = {} self._override_adv_data.update(state) self._update_parsed_data(state) def _get_adv_value(self, key: str) -> Any: """Return value from advertisement data.""" if self._override_adv_data and key in self._override_adv_data: _LOGGER.debug( "%s: Using override value for %s: %s", self.name, key, self._override_adv_data[key], ) return self._override_adv_data[key] if not self._sb_adv_data: return None return self._sb_adv_data.data["data"].get(key) def get_battery_percent(self) -> Any: """Return device battery level in percent.""" return self._get_adv_value("battery") def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: """Update device data from advertisement.""" # Only accept advertisements if the data is not missing # if we already have an advertisement with data self._device = advertisement.device async def get_device_data( self, retry: int | None = None, interface: int | None = None ) -> SwitchBotAdvertisement | None: """Find switchbot devices and their advertisement data.""" if retry is None: retry = self._retry_count if interface: _interface: int = interface else: _interface = int(self._interface.replace("hci", "")) _data = await GetSwitchbotDevices(interface=_interface).discover( retry=retry, scan_timeout=self._scan_timeout ) if self._device.address in _data: self._sb_adv_data = _data[self._device.address] return self._sb_adv_data async def _get_basic_info(self) -> bytes | None: """Return basic info of device.""" _data = await self._send_command( key=DEVICE_GET_BASIC_SETTINGS_KEY, retry=self._retry_count ) if _data in (b"\x07", b"\x00"): _LOGGER.error("Unsuccessful, please try again") return None return _data def _fire_callbacks(self) -> None: """Fire callbacks.""" _LOGGER.debug("%s: Fire callbacks", self.name) for callback in self._callbacks: callback() def subscribe(self, callback: Callable[[], None]) -> Callable[[], None]: """Subscribe to device notifications.""" self._callbacks.append(callback) def _unsub() -> None: """Unsubscribe from device notifications.""" self._callbacks.remove(callback) return _unsub async def update(self, interface: int | None = None) -> None: """Update position, battery percent and light level of device.""" if info := await self.get_basic_info(): self._last_full_update = time.monotonic() self._update_parsed_data(info) self._fire_callbacks() async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None return { "battery": _data[1], "firmware": _data[2] / 10.0, } def _check_command_result( self, result: bytes | None, index: int, values: set[int] ) -> bool: """Check command result.""" if not result or len(result) - 1 < index: result_hex = result.hex() if result else "None" raise SwitchbotOperationError( f"{self.name}: Sending command failed (result={result_hex} index={index} expected={values} rssi={self.rssi})" ) return result[index] in values def _update_parsed_data(self, new_data: dict[str, Any]) -> bool: """ Update data. Returns true if data has changed and False if not. """ if not self._sb_adv_data: _LOGGER.exception("No advertisement data to update") return old_data = self._sb_adv_data.data.get("data") or {} merged_data = _merge_data(old_data, new_data) if merged_data == old_data: return False self._set_parsed_data(self._sb_adv_data, merged_data) return True def _set_parsed_data( self, advertisement: SwitchBotAdvertisement, data: dict[str, Any] ) -> None: """Set data.""" self._sb_adv_data = replace( advertisement, data=self._sb_adv_data.data | {"data": data} ) def _set_advertisement_data(self, advertisement: SwitchBotAdvertisement) -> None: """Set advertisement data.""" new_data = advertisement.data.get("data") or {} if advertisement.active: # If we are getting active data, we can assume we are # getting active scans and we do not need to poll self._last_full_update = time.monotonic() if not self._sb_adv_data: self._sb_adv_data = advertisement elif new_data: self._update_parsed_data(new_data) self._override_adv_data = None def switch_mode(self) -> bool | None: """Return true or false from cache.""" # To get actual position call update() first. return self._get_adv_value("switchMode") def poll_needed(self, seconds_since_last_poll: float | None) -> bool: """Return if device needs polling.""" if ( seconds_since_last_poll is not None and seconds_since_last_poll < PASSIVE_POLL_INTERVAL ): return False time_since_last_full_update = time.monotonic() - self._last_full_update if time_since_last_full_update < PASSIVE_POLL_INTERVAL: return False return True class SwitchbotDevice(SwitchbotBaseDevice): """ Base Representation of a Switchbot Device. This base class consumes the advertisement data during connection. If the device sends stale advertisement data while connected, use SwitchbotDeviceOverrideStateDuringConnection instead. """ def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: """Update device data from advertisement.""" super().update_from_advertisement(advertisement) self._set_advertisement_data(advertisement) class SwitchbotEncryptedDevice(SwitchbotDevice): """A Switchbot device that uses encryption.""" def __init__( self, device: BLEDevice, key_id: str, encryption_key: str, model: SwitchbotModel, interface: int = 0, **kwargs: Any, ) -> None: """Switchbot base class constructor for encrypted devices.""" if len(key_id) == 0: raise ValueError("key_id is missing") elif len(key_id) != 2: raise ValueError("key_id is invalid") if len(encryption_key) == 0: raise ValueError("encryption_key is missing") elif len(encryption_key) != 32: raise ValueError("encryption_key is invalid") self._key_id = key_id self._encryption_key = bytearray.fromhex(encryption_key) self._iv: bytes | None = None self._cipher: bytes | None = None self._model = model super().__init__(device, None, interface, **kwargs) # Old non-async method preserved for backwards compatibility @classmethod def retrieve_encryption_key(cls, device_mac: str, username: str, password: str): async def async_fn(): async with aiohttp.ClientSession() as session: return await cls.async_retrieve_encryption_key( session, device_mac, username, password ) return asyncio.run(async_fn()) @classmethod async def async_retrieve_encryption_key( cls, session: aiohttp.ClientSession, device_mac: str, username: str, password: str, ) -> dict: """Retrieve lock key from internal SwitchBot API.""" device_mac = device_mac.replace(":", "").replace("-", "").upper() try: auth_result = await cls.api_request( session, "account", "account/api/v1/user/login", { "clientId": SWITCHBOT_APP_CLIENT_ID, "username": username, "password": password, "grantType": "password", "verifyCode": "", }, ) auth_headers = {"authorization": auth_result["access_token"]} except Exception as err: raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err try: userinfo = await cls.api_request( session, "account", "account/api/v1/user/userinfo", {}, auth_headers ) if "botRegion" in userinfo and userinfo["botRegion"] != "": region = userinfo["botRegion"] else: region = "us" except Exception as err: raise SwitchbotAccountConnectionError( f"Failed to retrieve SwitchBot Account user details: {err}" ) from err try: device_info = await cls.api_request( session, f"wonderlabs.{region}", "wonder/keys/v1/communicate", { "device_mac": device_mac, "keyType": "user", }, auth_headers, ) return { "key_id": device_info["communicationKey"]["keyId"], "encryption_key": device_info["communicationKey"]["key"], } except Exception as err: raise SwitchbotAccountConnectionError( f"Failed to retrieve encryption key from SwitchBot Account: {err}" ) from err @classmethod async def verify_encryption_key( cls, device: BLEDevice, key_id: str, encryption_key: str, model: SwitchbotModel, **kwargs: Any, ) -> bool: try: switchbot_device = cls( device, key_id=key_id, encryption_key=encryption_key, model=model ) except ValueError: return False try: info = await switchbot_device.get_basic_info() except SwitchbotOperationError: return False return info is not None async def _send_command( self, key: str, retry: int | None = None, encrypt: bool = True ) -> bytes | None: if not encrypt: return await super()._send_command(key[:2] + "000000" + key[2:], retry) result = await self._ensure_encryption_initialized() if not result: _LOGGER.error("Failed to initialize encryption") return None encrypted = ( key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:]) ) result = await super()._send_command(encrypted, retry) return result[:1] + self._decrypt(result[4:]) async def _ensure_encryption_initialized(self) -> bool: if self._iv is not None: return True result = await self._send_command( COMMAND_GET_CK_IV + self._key_id, encrypt=False ) ok = self._check_command_result(result, 0, {1}) if ok: self._iv = result[4:] return ok async def _execute_disconnect(self) -> None: await super()._execute_disconnect() self._iv = None self._cipher = None def _get_cipher(self) -> Cipher: if self._cipher is None: self._cipher = Cipher( algorithms.AES128(self._encryption_key), modes.CTR(self._iv) ) return self._cipher def _encrypt(self, data: str) -> str: if len(data) == 0: return "" encryptor = self._get_cipher().encryptor() return (encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()).hex() def _decrypt(self, data: bytearray) -> bytes: if len(data) == 0: return b"" decryptor = self._get_cipher().decryptor() return decryptor.update(data) + decryptor.finalize() class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice): """ Base Representation of a Switchbot Device. This base class ignores the advertisement data during connection and uses the data from the device instead. """ def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: super().update_from_advertisement(advertisement) if self._client and self._client.is_connected: # We do not consume the advertisement data if we are connected # to the device. This is because the advertisement data is not # updated when the device is connected for some devices. _LOGGER.debug("%s: Ignore advertisement data during connection", self.name) return self._set_advertisement_data(advertisement) class SwitchbotSequenceDevice(SwitchbotDevice): """ A Switchbot sequence device. This class must not use SwitchbotDeviceOverrideStateDuringConnection because it needs to know when the sequence_number has changed. """ def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: """Update device data from advertisement.""" current_state = self._get_adv_value("sequence_number") super().update_from_advertisement(advertisement) new_state = self._get_adv_value("sequence_number") _LOGGER.debug( "%s: update advertisement: %s (seq before: %s) (seq after: %s)", self.name, advertisement, current_state, new_state, ) if current_state != new_state: create_background_task(self.update()) pySwitchbot-0.57.1/switchbot/devices/evaporative_humidifier.py000066400000000000000000000163571476535110100246760ustar00rootroot00000000000000import logging from typing import Any from bleak.backends.device import BLEDevice from ..const import SwitchbotModel from ..const.evaporative_humidifier import ( TARGET_HUMIDITY_MODES, HumidifierMode, HumidifierWaterLevel, ) from ..models import SwitchBotAdvertisement from .device import SwitchbotEncryptedDevice _LOGGER = logging.getLogger(__name__) COMMAND_HEADER = "57" COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103" COMMAND_TURN_ON = f"{COMMAND_HEADER}0f430101" COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f430100" COMMAND_CHILD_LOCK_ON = f"{COMMAND_HEADER}0f430501" COMMAND_CHILD_LOCK_OFF = f"{COMMAND_HEADER}0f430500" COMMAND_AUTO_DRY_ON = f"{COMMAND_HEADER}0f430a01" COMMAND_AUTO_DRY_OFF = f"{COMMAND_HEADER}0f430a02" COMMAND_SET_MODE = f"{COMMAND_HEADER}0f4302" COMMAND_GET_BASIC_INFO = f"{COMMAND_HEADER}000300" MODES_COMMANDS = { HumidifierMode.HIGH: "010100", HumidifierMode.MEDIUM: "010200", HumidifierMode.LOW: "010300", HumidifierMode.QUIET: "010400", HumidifierMode.TARGET_HUMIDITY: "0200", HumidifierMode.SLEEP: "0300", HumidifierMode.AUTO: "040000", } class SwitchbotEvaporativeHumidifier(SwitchbotEncryptedDevice): """Representation of a Switchbot Evaporative Humidifier""" def __init__( self, device: BLEDevice, key_id: str, encryption_key: str, interface: int = 0, model: SwitchbotModel = SwitchbotModel.EVAPORATIVE_HUMIDIFIER, **kwargs: Any, ) -> None: self._force_next_update = False super().__init__(device, key_id, encryption_key, model, interface, **kwargs) @classmethod async def verify_encryption_key( cls, device: BLEDevice, key_id: str, encryption_key: str, model: SwitchbotModel = SwitchbotModel.EVAPORATIVE_HUMIDIFIER, **kwargs: Any, ) -> bool: return await super().verify_encryption_key( device, key_id, encryption_key, model, **kwargs ) def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: """Update device data from advertisement.""" super().update_from_advertisement(advertisement) _LOGGER.debug( "%s: update advertisement: %s", self.name, advertisement, ) async def _get_basic_info(self) -> bytes | None: """Return basic info of device.""" _data = await self._send_command( key=COMMAND_GET_BASIC_INFO, retry=self._retry_count ) if _data in (b"\x07", b"\x00"): _LOGGER.error("Unsuccessful, please try again") return None return _data async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None # Not 100% sure about this data, will verify once a firmware update is available return { "firmware": _data[2] / 10.0, } async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(COMMAND_TURN_ON) if ok := self._check_command_result(result, 0, {1}): self._override_state({"isOn": True}) self._fire_callbacks() return ok async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(COMMAND_TURN_OFF) if ok := self._check_command_result(result, 0, {1}): self._override_state({"isOn": False}) self._fire_callbacks() return ok async def set_mode( self, mode: HumidifierMode, target_humidity: int | None = None ) -> bool: """Set device mode.""" if mode == HumidifierMode.DRYING_FILTER: return await self.start_drying_filter() elif mode not in MODES_COMMANDS: raise ValueError("Invalid mode") command = COMMAND_SET_MODE + MODES_COMMANDS[mode] if mode in TARGET_HUMIDITY_MODES: if target_humidity is None: raise TypeError("target_humidity is required") command += f"{target_humidity:02x}" result = await self._send_command(command) if ok := self._check_command_result(result, 0, {1}): self._override_state({"mode": mode}) if mode == HumidifierMode.TARGET_HUMIDITY and target_humidity is not None: self._override_state({"target_humidity": target_humidity}) self._fire_callbacks() return ok async def set_child_lock(self, enabled: bool) -> bool: """Set child lock.""" result = await self._send_command( COMMAND_CHILD_LOCK_ON if enabled else COMMAND_CHILD_LOCK_OFF ) if ok := self._check_command_result(result, 0, {1}): self._override_state({"child_lock": enabled}) self._fire_callbacks() return ok async def start_drying_filter(self): """Start drying filter.""" result = await self._send_command(COMMAND_TURN_ON + "08") if ok := self._check_command_result(result, 0, {1}): self._override_state({"mode": HumidifierMode.DRYING_FILTER}) self._fire_callbacks() return ok async def stop_drying_filter(self): """Stop drying filter.""" result = await self._send_command(COMMAND_TURN_OFF) if ok := self._check_command_result(result, 0, {0}): self._override_state({"isOn": False, "mode": None}) self._fire_callbacks() return ok def is_on(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("isOn") def get_mode(self) -> HumidifierMode | None: """Return state from cache.""" return self._get_adv_value("mode") def is_child_lock_enabled(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("child_lock") def is_over_humidify_protection_enabled(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("over_humidify_protection") def is_tank_removed(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("tank_removed") def is_filter_missing(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("filter_missing") def is_filter_alert_on(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("filter_alert") def is_tilted_alert_on(self) -> bool | None: """Return state from cache.""" return self._get_adv_value("tilted_alert") def get_water_level(self) -> HumidifierWaterLevel | None: """Return state from cache.""" return self._get_adv_value("water_level") def get_filter_run_time(self) -> int | None: """Return state from cache.""" return self._get_adv_value("filter_run_time") def get_target_humidity(self) -> int | None: """Return state from cache.""" return self._get_adv_value("target_humidity") def get_humidity(self) -> int | None: """Return state from cache.""" return self._get_adv_value("humidity") def get_temperature(self) -> float | None: """Return state from cache.""" return self._get_adv_value("temperature") pySwitchbot-0.57.1/switchbot/devices/humidifier.py000066400000000000000000000065511476535110100222640ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import time from .device import REQ_HEADER, SwitchbotDevice HUMIDIFIER_COMMAND_HEADER = "4381" HUMIDIFIER_REQUEST = f"{REQ_HEADER}4481" HUMIDIFIER_COMMAND = f"{REQ_HEADER}{HUMIDIFIER_COMMAND_HEADER}" HUMIDIFIER_OFF_KEY = f"{HUMIDIFIER_COMMAND}010080FFFFFFFF" HUMIDIFIER_ON_KEY = f"{HUMIDIFIER_COMMAND}010180FFFFFFFF" ## # OFF 570F 4381 0100 80FF FFFF FF # ON 570F 4381 0101 80FF FFFF FF # AUTO 570F 4381 0101 80FF FFFF FF # 1. 570F 4381 0101 22FF FFFF FF # 2. 570F 4381 0101 43FF FFFF FF # 3 . 570F 4381 0101 64FF FFFF FF MANUAL_BUTTON_PRESSES_TO_LEVEL = { 101: 33, 102: 66, 103: 100, } class SwitchbotHumidifier(SwitchbotDevice): """Representation of a Switchbot humidifier.""" async def update(self, interface: int | None = None) -> None: """Update state of device.""" # No battery here self._last_full_update = time.monotonic() def _generate_command( self, on: bool | None = None, level: int | None = None ) -> str: """Generate command.""" if level is None: level = self.get_target_humidity() or 128 if on is None: on = self.is_on() on_hex = "01" if on else "00" return f"{HUMIDIFIER_COMMAND}01{on_hex}{level:02X}FFFFFFFF" async def _async_set_state(self, state: bool) -> bool: level = self.get_target_humidity() or 128 result = await self._send_command(self._generate_command(on=state, level=level)) ret = self._check_command_result(result, 0, {0x01}) self._override_state({"isOn": state, "level": level}) self._fire_callbacks() return ret async def turn_on(self) -> bool: """Turn device on.""" await self._async_set_state(True) async def turn_off(self) -> bool: """Turn device off.""" await self._async_set_state(False) async def set_level(self, level: int) -> bool: """Set level.""" assert 1 <= level <= 100, "Level must be between 1 and 100" await self._set_level(level) async def _set_level(self, level: int) -> bool: """Set level.""" result = await self._send_command(self._generate_command(level=level)) ret = self._check_command_result(result, 0, {0x01}) self._override_state({"level": level}) self._fire_callbacks() return ret async def async_set_auto(self) -> bool: """Set auto mode.""" await self._set_level(128) async def async_set_manual(self) -> bool: """Set manual mode.""" await self._set_level(50) def is_auto(self) -> bool: """Return auto state from cache.""" return self.get_level() in (228, 128) def get_level(self) -> int | None: """Return level state from cache.""" return self._get_adv_value("level") def is_on(self) -> bool | None: """Return switch state from cache.""" return self._get_adv_value("isOn") def get_target_humidity(self) -> int | None: """Return target humidity from cache.""" level = self.get_level() if self.is_auto(): return None return MANUAL_BUTTON_PRESSES_TO_LEVEL.get(level, level) def poll_needed(self, last_poll_time: float | None) -> bool: """Return if device needs polling.""" return False pySwitchbot-0.57.1/switchbot/devices/keypad.py000066400000000000000000000000431476535110100214020ustar00rootroot00000000000000from __future__ import annotations pySwitchbot-0.57.1/switchbot/devices/light_strip.py000066400000000000000000000056441476535110100224710ustar00rootroot00000000000000from __future__ import annotations import logging from .base_light import SwitchbotSequenceBaseLight from .device import REQ_HEADER, ColorMode STRIP_COMMMAND_HEADER = "4901" STRIP_REQUEST = f"{REQ_HEADER}4A01" STRIP_COMMAND = f"{REQ_HEADER}{STRIP_COMMMAND_HEADER}" # Strip keys STRIP_ON_KEY = f"{STRIP_COMMAND}01" STRIP_OFF_KEY = f"{STRIP_COMMAND}02" RGB_BRIGHTNESS_KEY = f"{STRIP_COMMAND}12" BRIGHTNESS_KEY = f"{STRIP_COMMAND}14" _LOGGER = logging.getLogger(__name__) class SwitchbotLightStrip(SwitchbotSequenceBaseLight): """Representation of a Switchbot light strip.""" @property def color_modes(self) -> set[ColorMode]: """Return the supported color modes.""" return {ColorMode.RGB} async def update(self) -> None: """Update state of device.""" result = await self._send_command(STRIP_REQUEST) self._update_state(result) await super().update() async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(STRIP_ON_KEY) self._update_state(result) return self._check_command_result(result, 1, {0x80}) async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(STRIP_OFF_KEY) self._update_state(result) return self._check_command_result(result, 1, {0x00}) async def set_brightness(self, brightness: int) -> bool: """Set brightness.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}") self._update_state(result) return self._check_command_result(result, 1, {0x80}) async def set_color_temp(self, brightness: int, color_temp: int) -> bool: """Set color temp.""" # not supported on this device async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool: """Set rgb.""" assert 0 <= brightness <= 100, "Brightness must be between 0 and 100" assert 0 <= r <= 255, "r must be between 0 and 255" assert 0 <= g <= 255, "g must be between 0 and 255" assert 0 <= b <= 255, "b must be between 0 and 255" result = await self._send_command( f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}" ) self._update_state(result) return self._check_command_result(result, 1, {0x80}) def _update_state(self, result: bytes | None) -> None: """Update device state.""" if not result or len(result) < 10: return self._state["r"] = result[3] self._state["g"] = result[4] self._state["b"] = result[5] self._override_state( { "isOn": result[1] == 0x80, "color_mode": result[10], } ) _LOGGER.debug("%s: update state: %s = %s", self.name, result.hex(), self._state) self._fire_callbacks() pySwitchbot-0.57.1/switchbot/devices/lock.py000066400000000000000000000175501476535110100210700ustar00rootroot00000000000000"""Library to handle connection with Switchbot Lock.""" from __future__ import annotations import logging import time from typing import Any from bleak.backends.device import BLEDevice from ..const import SwitchbotModel from ..const.lock import LockStatus from .device import SwitchbotEncryptedDevice COMMAND_HEADER = "57" COMMAND_LOCK_INFO = { SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4f8101", SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8102", } COMMAND_UNLOCK = { SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011080", SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000080", } COMMAND_UNLOCK_WITHOUT_UNLATCH = { SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e010110a0", SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e01010000a0", } COMMAND_LOCK = { SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4e01011000", SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4e0101000000", } COMMAND_ENABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e01001e00008101" COMMAND_DISABLE_NOTIFICATIONS = f"{COMMAND_HEADER}0e00" MOVING_STATUSES = {LockStatus.LOCKING, LockStatus.UNLOCKING} BLOCKED_STATUSES = {LockStatus.LOCKING_STOP, LockStatus.UNLOCKING_STOP} REST_STATUSES = {LockStatus.LOCKED, LockStatus.UNLOCKED, LockStatus.NOT_FULLY_LOCKED} _LOGGER = logging.getLogger(__name__) COMMAND_RESULT_EXPECTED_VALUES = {1, 6} # The return value of the command is 1 when the command is successful. # The return value of the command is 6 when the command is successful but the battery is low. class SwitchbotLock(SwitchbotEncryptedDevice): """Representation of a Switchbot Lock.""" def __init__( self, device: BLEDevice, key_id: str, encryption_key: str, interface: int = 0, model: SwitchbotModel = SwitchbotModel.LOCK, **kwargs: Any, ) -> None: if model not in (SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO): raise ValueError("initializing SwitchbotLock with a non-lock model") self._notifications_enabled: bool = False super().__init__(device, key_id, encryption_key, model, interface, **kwargs) @classmethod async def verify_encryption_key( cls, device: BLEDevice, key_id: str, encryption_key: str, model: SwitchbotModel = SwitchbotModel.LOCK, **kwargs: Any, ) -> bool: return await super().verify_encryption_key( device, key_id, encryption_key, model, **kwargs ) async def lock(self) -> bool: """Send lock command.""" return await self._lock_unlock( COMMAND_LOCK[self._model], {LockStatus.LOCKED, LockStatus.LOCKING} ) async def unlock(self) -> bool: """Send unlock command. If unlatch feature is enabled in EU firmware, also unlatches door""" return await self._lock_unlock( COMMAND_UNLOCK[self._model], {LockStatus.UNLOCKED, LockStatus.UNLOCKING} ) async def unlock_without_unlatch(self) -> bool: """Send unlock command. This command will not unlatch the door.""" return await self._lock_unlock( COMMAND_UNLOCK_WITHOUT_UNLATCH[self._model], {LockStatus.UNLOCKED, LockStatus.UNLOCKING, LockStatus.NOT_FULLY_LOCKED}, ) def _parse_basic_data(self, basic_data: bytes) -> dict[str, Any]: """Parse basic data from lock.""" return { "battery": basic_data[1], "firmware": basic_data[2] / 10.0, } async def _lock_unlock( self, command: str, ignore_statuses: set[LockStatus] ) -> bool: status = self.get_lock_status() if status is None: await self.update() status = self.get_lock_status() if status in ignore_statuses: return True await self._enable_notifications() result = await self._send_command(command) status = self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES) # Also update the battery and firmware version if basic_data := await self._get_basic_info(): self._last_full_update = time.monotonic() if len(basic_data) >= 3: self._update_parsed_data(self._parse_basic_data(basic_data)) else: _LOGGER.warning("Invalid basic data received: %s", basic_data) self._fire_callbacks() return status async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic status.""" lock_raw_data = await self._get_lock_info() if not lock_raw_data: return None basic_data = await self._get_basic_info() if not basic_data: return None return self._parse_lock_data(lock_raw_data[1:]) | self._parse_basic_data( basic_data ) def is_calibrated(self) -> Any: """Return True if lock is calibrated.""" return self._get_adv_value("calibration") def get_lock_status(self) -> LockStatus: """Return lock status.""" return self._get_adv_value("status") def is_door_open(self) -> bool: """Return True if door is open.""" return self._get_adv_value("door_open") def is_unclosed_alarm_on(self) -> bool: """Return True if unclosed door alarm is on.""" return self._get_adv_value("unclosed_alarm") def is_unlocked_alarm_on(self) -> bool: """Return True if lock unlocked alarm is on.""" return self._get_adv_value("unlocked_alarm") def is_auto_lock_paused(self) -> bool: """Return True if auto lock is paused.""" return self._get_adv_value("auto_lock_paused") def is_night_latch_enabled(self) -> bool: """Return True if Night Latch is enabled on EU firmware.""" return self._get_adv_value("night_latch") async def _get_lock_info(self) -> bytes | None: """Return lock info of device.""" _data = await self._send_command( key=COMMAND_LOCK_INFO[self._model], retry=self._retry_count ) if not self._check_command_result(_data, 0, COMMAND_RESULT_EXPECTED_VALUES): _LOGGER.error("Unsuccessful, please try again") return None return _data async def _enable_notifications(self) -> bool: if self._notifications_enabled: return True result = await self._send_command(COMMAND_ENABLE_NOTIFICATIONS) if self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES): self._notifications_enabled = True return self._notifications_enabled async def _disable_notifications(self) -> bool: if not self._notifications_enabled: return True result = await self._send_command(COMMAND_DISABLE_NOTIFICATIONS) if self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES): self._notifications_enabled = False return not self._notifications_enabled def _notification_handler(self, _sender: int, data: bytearray) -> None: if self._notifications_enabled and self._check_command_result(data, 0, {0xF}): self._update_lock_status(data) else: super()._notification_handler(_sender, data) def _update_lock_status(self, data: bytearray) -> None: lock_data = self._parse_lock_data(self._decrypt(data[4:])) if self._update_parsed_data(lock_data): # We leave notifications enabled in case # the lock is operated manually before we # disconnect. self._reset_disconnect_timer() self._fire_callbacks() @staticmethod def _parse_lock_data(data: bytes) -> dict[str, Any]: return { "calibration": bool(data[0] & 0b10000000), "status": LockStatus((data[0] & 0b01110000) >> 4), "door_open": bool(data[0] & 0b00000100), "unclosed_alarm": bool(data[1] & 0b00100000), "unlocked_alarm": bool(data[1] & 0b00010000), } pySwitchbot-0.57.1/switchbot/devices/meter.py000066400000000000000000000000431476535110100212410ustar00rootroot00000000000000from __future__ import annotations pySwitchbot-0.57.1/switchbot/devices/motion.py000066400000000000000000000000431476535110100214320ustar00rootroot00000000000000from __future__ import annotations pySwitchbot-0.57.1/switchbot/devices/plug.py000066400000000000000000000025651476535110100211070ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations import time from .device import REQ_HEADER, SwitchbotDeviceOverrideStateDuringConnection # Plug Mini keys PLUG_ON_KEY = f"{REQ_HEADER}50010180" PLUG_OFF_KEY = f"{REQ_HEADER}50010100" class SwitchbotPlugMini(SwitchbotDeviceOverrideStateDuringConnection): """Representation of a Switchbot plug mini.""" async def update(self, interface: int | None = None) -> None: """Update state of device.""" # No battery here self._last_full_update = time.monotonic() async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(PLUG_ON_KEY) ret = self._check_command_result(result, 1, {0x80}) self._override_state({"isOn": True}) self._fire_callbacks() return ret async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(PLUG_OFF_KEY) ret = self._check_command_result(result, 1, {0x80}) self._override_state({"isOn": False}) self._fire_callbacks() return ret def is_on(self) -> bool | None: """Return switch state from cache.""" return self._get_adv_value("isOn") def poll_needed(self, last_poll_time: float | None) -> bool: """Return if device needs polling.""" return False pySwitchbot-0.57.1/switchbot/devices/relay_switch.py000066400000000000000000000116151476535110100226310ustar00rootroot00000000000000import logging import time from typing import Any from bleak.backends.device import BLEDevice from ..const import SwitchbotModel from ..models import SwitchBotAdvertisement from .device import SwitchbotEncryptedDevice _LOGGER = logging.getLogger(__name__) COMMAND_HEADER = "57" COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f70010000" COMMAND_TURN_ON = f"{COMMAND_HEADER}0f70010100" COMMAND_TOGGLE = f"{COMMAND_HEADER}0f70010200" COMMAND_GET_VOLTAGE_AND_CURRENT = f"{COMMAND_HEADER}0f7106000000" COMMAND_GET_SWITCH_STATE = f"{COMMAND_HEADER}0f7101000000" PASSIVE_POLL_INTERVAL = 10 * 60 class SwitchbotRelaySwitch(SwitchbotEncryptedDevice): """Representation of a Switchbot relay switch 1pm.""" def __init__( self, device: BLEDevice, key_id: str, encryption_key: str, interface: int = 0, model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM, **kwargs: Any, ) -> None: self._force_next_update = False super().__init__(device, key_id, encryption_key, model, interface, **kwargs) @classmethod async def verify_encryption_key( cls, device: BLEDevice, key_id: str, encryption_key: str, model: SwitchbotModel = SwitchbotModel.RELAY_SWITCH_1PM, **kwargs: Any, ) -> bool: return await super().verify_encryption_key( device, key_id, encryption_key, model, **kwargs ) def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None: """Update device data from advertisement.""" # Obtain voltage and current through command. adv_data = advertisement.data["data"] if previous_voltage := self._get_adv_value("voltage"): adv_data["voltage"] = previous_voltage if previous_current := self._get_adv_value("current"): adv_data["current"] = previous_current current_state = self._get_adv_value("sequence_number") super().update_from_advertisement(advertisement) new_state = self._get_adv_value("sequence_number") _LOGGER.debug( "%s: update advertisement: %s (seq before: %s) (seq after: %s)", self.name, advertisement, current_state, new_state, ) if current_state != new_state: self._force_next_update = True async def update(self, interface: int | None = None) -> None: """Update state of device.""" if info := await self.get_voltage_and_current(): self._last_full_update = time.monotonic() self._update_parsed_data(info) self._fire_callbacks() async def get_voltage_and_current(self) -> dict[str, Any] | None: """Get voltage and current because advtisement don't have these""" result = await self._send_command(COMMAND_GET_VOLTAGE_AND_CURRENT) ok = self._check_command_result(result, 0, {1}) if ok: return { "voltage": ((result[9] << 8) + result[10]) / 10, "current": (result[11] << 8) + result[12], } return None async def get_basic_info(self) -> dict[str, Any] | None: """Get the current state of the switch.""" result = await self._send_command(COMMAND_GET_SWITCH_STATE) if self._check_command_result(result, 0, {1}): return { "is_on": result[1] & 0x01 != 0, } return None def poll_needed(self, seconds_since_last_poll: float | None) -> bool: """Return if device needs polling.""" if self._force_next_update: self._force_next_update = False return True if ( seconds_since_last_poll is not None and seconds_since_last_poll < PASSIVE_POLL_INTERVAL ): return False time_since_last_full_update = time.monotonic() - self._last_full_update if time_since_last_full_update < PASSIVE_POLL_INTERVAL: return False return True async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(COMMAND_TURN_ON) ok = self._check_command_result(result, 0, {1}) if ok: self._override_state({"isOn": True}) self._fire_callbacks() return ok async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(COMMAND_TURN_OFF) ok = self._check_command_result(result, 0, {1}) if ok: self._override_state({"isOn": False}) self._fire_callbacks() return ok async def async_toggle(self, **kwargs) -> bool: """Toggle device.""" result = await self._send_command(COMMAND_TOGGLE) status = self._check_command_result(result, 0, {1}) return status def is_on(self) -> bool | None: """Return switch state from cache.""" return self._get_adv_value("isOn") pySwitchbot-0.57.1/switchbot/discovery.py000066400000000000000000000133421476535110100205200ustar00rootroot00000000000000"""Discover switchbot devices.""" from __future__ import annotations import asyncio import logging import bleak from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from .adv_parser import parse_advertisement_data from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DEFAULT_SCAN_TIMEOUT from .models import SwitchBotAdvertisement _LOGGER = logging.getLogger(__name__) CONNECT_LOCK = asyncio.Lock() class GetSwitchbotDevices: """Scan for all Switchbot devices and return by type.""" def __init__(self, interface: int = 0) -> None: """Get switchbot devices class constructor.""" self._interface = f"hci{interface}" self._adv_data: dict[str, SwitchBotAdvertisement] = {} def detection_callback( self, device: BLEDevice, advertisement_data: AdvertisementData, ) -> None: """Callback for device detection.""" discovery = parse_advertisement_data(device, advertisement_data) if discovery: self._adv_data[discovery.address] = discovery async def discover( self, retry: int = DEFAULT_RETRY_COUNT, scan_timeout: int = DEFAULT_SCAN_TIMEOUT ) -> dict: """Find switchbot devices and their advertisement data.""" devices = None devices = bleak.BleakScanner( detection_callback=self.detection_callback, # TODO: Find new UUIDs to filter on. For example, see # https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/4ad138bb09f0fbbfa41b152ca327a78c1d0b6ba9/devicetypes/meter.md adapter=self._interface, ) async with CONNECT_LOCK: await devices.start() await asyncio.sleep(scan_timeout) await devices.stop() if devices is None: if retry < 1: _LOGGER.error( "Scanning for Switchbot devices failed. Stop trying", exc_info=True ) return self._adv_data _LOGGER.warning( "Error scanning for Switchbot devices. Retrying (remaining: %d)", retry, ) await asyncio.sleep(DEFAULT_RETRY_TIMEOUT) return await self.discover(retry - 1, scan_timeout) return self._adv_data async def _get_devices_by_model( self, model: str, ) -> dict: """Get switchbot devices by type.""" if not self._adv_data: await self.discover() return { address: adv for address, adv in self._adv_data.items() if adv.data.get("model") == model } async def get_blind_tilts(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoBlindTilt/BlindTilts devices with services data.""" regular_blinds = await self._get_devices_by_model("x") pairing_blinds = await self._get_devices_by_model("X") return {**regular_blinds, **pairing_blinds} async def get_curtains(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoCurtain/Curtains devices with services data.""" regular_curtains = await self._get_devices_by_model("c") pairing_curtains = await self._get_devices_by_model("C") regular_curtains3 = await self._get_devices_by_model("{") pairing_curtains3 = await self._get_devices_by_model("[") return { **regular_curtains, **pairing_curtains, **regular_curtains3, **pairing_curtains3, } async def get_bots(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoHand/Bot devices with services data.""" return await self._get_devices_by_model("H") async def get_tempsensors(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoSensorTH/Temp sensor devices with services data.""" base_meters = await self._get_devices_by_model("T") plus_meters = await self._get_devices_by_model("i") io_meters = await self._get_devices_by_model("w") hub2_meters = await self._get_devices_by_model("v") return {**base_meters, **plus_meters, **io_meters, **hub2_meters} async def get_contactsensors(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoContact/Contact sensor devices with services data.""" return await self._get_devices_by_model("d") async def get_leakdetectors(self) -> dict[str, SwitchBotAdvertisement]: """Return all Leak Detectors with services data.""" return await self._get_devices_by_model("&") async def get_locks(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoLock/Locks devices with services data.""" locks = await self._get_devices_by_model("o") lock_pros = await self._get_devices_by_model("$") return {**locks, **lock_pros} async def get_keypads(self) -> dict[str, SwitchBotAdvertisement]: """Return all WoKeypad/Keypad devices with services data.""" return await self._get_devices_by_model("y") async def get_humidifiers(self) -> dict[str, SwitchBotAdvertisement]: """Return all humidifier devices with services data.""" humidifiers = await self._get_devices_by_model("e") evaporative_humidifiers = await self._get_devices_by_model("#") return {**humidifiers, **evaporative_humidifiers} async def get_device_data( self, address: str ) -> dict[str, SwitchBotAdvertisement] | None: """Return data for specific device.""" if not self._adv_data: await self.discover() return { device: adv for device, adv in self._adv_data.items() # MacOS uses UUIDs instead of MAC addresses if adv.data.get("address") == address } pySwitchbot-0.57.1/switchbot/enum.py000066400000000000000000000012621476535110100174530ustar00rootroot00000000000000"""Enum backports from standard lib.""" from __future__ import annotations from enum import Enum from typing import Any, TypeVar _StrEnumT = TypeVar("_StrEnumT", bound="StrEnum") class StrEnum(str, Enum): """Partial backport of Python 3.11's StrEnum for our basic use cases.""" def __new__( cls: type[_StrEnumT], value: str, *args: Any, **kwargs: Any ) -> _StrEnumT: """Create a new StrEnum instance.""" if not isinstance(value, str): raise TypeError(f"{value!r} is not a string") return super().__new__(cls, value, *args, **kwargs) def __str__(self) -> str: """Return self.value.""" return str(self.value) pySwitchbot-0.57.1/switchbot/helpers.py000066400000000000000000000007031476535110100201500ustar00rootroot00000000000000from __future__ import annotations import asyncio from collections.abc import Coroutine from typing import Any, TypeVar _R = TypeVar("_R") _BACKGROUND_TASKS: set[asyncio.Task[Any]] = set() def create_background_task(target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]: """Create a background task.""" task = asyncio.create_task(target) _BACKGROUND_TASKS.add(task) task.add_done_callback(_BACKGROUND_TASKS.remove) return task pySwitchbot-0.57.1/switchbot/models.py000066400000000000000000000005641476535110100177760ustar00rootroot00000000000000"""Library to handle connection with Switchbot.""" from __future__ import annotations from dataclasses import dataclass from typing import Any from bleak.backends.device import BLEDevice @dataclass class SwitchBotAdvertisement: """Switchbot advertisement.""" address: str data: dict[str, Any] device: BLEDevice rssi: int active: bool = False pySwitchbot-0.57.1/tests/000077500000000000000000000000001476535110100152705ustar00rootroot00000000000000pySwitchbot-0.57.1/tests/__init__.py000066400000000000000000000000001476535110100173670ustar00rootroot00000000000000pySwitchbot-0.57.1/tests/test_adv_parser.py000066400000000000000000001771701476535110100210440ustar00rootroot00000000000000from __future__ import annotations from typing import Any from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from switchbot import SwitchbotModel from switchbot.adv_parser import parse_advertisement_data from switchbot.const.lock import LockStatus from switchbot.models import SwitchBotAdvertisement ADVERTISEMENT_DATA_DEFAULTS = { "local_name": "", "manufacturer_data": {}, "service_data": {}, "service_uuids": [], "rssi": -127, "platform_data": ((),), "tx_power": -127, } BLE_DEVICE_DEFAULTS = { "name": None, "rssi": -127, "details": None, } def generate_ble_device( address: str | None = None, name: str | None = None, details: Any | None = None, rssi: int | None = None, **kwargs: Any, ) -> BLEDevice: """Generate a BLEDevice with defaults.""" new = kwargs.copy() if address is not None: new["address"] = address if name is not None: new["name"] = name if details is not None: new["details"] = details if rssi is not None: new["rssi"] = rssi for key, value in BLE_DEVICE_DEFAULTS.items(): new.setdefault(key, value) return BLEDevice(**new) def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() for key, value in ADVERTISEMENT_DATA_DEFAULTS.items(): new.setdefault(key, value) return AdvertisementData(**new) def test_parse_advertisement_data_curtain(): """Test parse_advertisement_data for curtain.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xe7\xabF\xac\x8f\x92|\x0f\x00\x11\x04"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0X\x00\x11\x04"}, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0X\x00\x11\x04", "data": { "calibration": True, "battery": 88, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=True, ) def test_parse_advertisement_data_curtain_passive(): """Test parse_advertisement_data for curtain passive.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xe7\xabF\xac\x8f\x92|\x0f\x00\x11\x04"}, service_data={}, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.CURTAIN) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": None, "data": { "calibration": None, "battery": None, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=False, ) def test_parse_advertisement_data_curtain_passive_12_bytes(): """Test parse_advertisement_data for curtain passive.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xe7\xabF\xac\x8f\x92|\x0f\x00\x11\x04\x00"}, service_data={}, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.CURTAIN) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": None, "data": { "calibration": None, "battery": None, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=False, ) def test_parse_advertisement_data_curtain_position_zero(): """Test parse_advertisement_data for curtain position zero.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={89: b"\xc1\xc7'}U\xab"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0\xced\x11\x04"}, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=-52, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xd0\xced\x11\x04", "data": { "calibration": True, "battery": 78, "inMotion": False, "position": 0, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-52, active=True, ) def test_parse_advertisement_data_curtain_firmware_six_position_100(): """Test parse_advertisement_data with firmware six for curtain position 100.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={ 89: b"\xf5\x98\x94\x08\xa0\xe7", 2409: b'\xf5\x98\x94\x08\xa0\xe7\x9b\x0f\x00"\x04', }, service_data={ "00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0H\x00\x12\x04", "0000fd3d-0000-1000-8000-00805f9b34fb": b'c\xc0G\x00"\x04', }, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=-62, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b'c\xc0G\x00"\x04', "data": { "calibration": True, "battery": 71, "inMotion": False, "position": 100, "lightLevel": 2, "deviceChain": 2, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-62, active=True, ) def test_parse_advertisement_data_curtain_firmware_six_position_100_other_rssi(): """Test parse_advertisement_data with firmware six for curtain position 100 other rssi.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={ 89: b"\xf5\x98\x94\x08\xa0\xe7", 2409: b'\xf5\x98\x94\x08\xa0\xe7\xa5\x0fc"\x04', }, service_data={ "00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0H\x00\x12\x04", "0000fd3d-0000-1000-8000-00805f9b34fb": b'c\xc0Gc"\x04', }, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b'c\xc0Gc"\x04', "data": { "calibration": True, "battery": 71, "inMotion": False, "position": 1, "lightLevel": 2, "deviceChain": 2, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-67, active=True, ) def test_parse_advertisement_data_curtain_fully_closed(): """Test parse_advertisement_data with firmware six fully closed.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={2409: b"\xc1\xc7'}U\xab\"\x0fd\x11\x04"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0Sd\x11\x04"}, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=1, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0Sd\x11\x04", "data": { "calibration": True, "battery": 83, "inMotion": False, "position": 0, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=1, active=True, ) def test_parse_advertisement_data_curtain_fully_open(): """Test parse_advertisement_data with firmware six fully open.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={2409: b"\xc1\xc7'}U\xab%\x0f\x00\x11\x04"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0S\x00\x11\x04"}, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=1, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0S\x00\x11\x04", "data": { "calibration": True, "battery": 83, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=1, active=True, ) def test_parse_advertisement_data_curtain3(): """Test parse_advertisement_data for curtain 3.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xaa\xbb\xcc\xdd\xee\xff\xf7\x07\x00\x11\x04\x00\x49" }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"{\xc0\x49\x00\x11\x04"}, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"{\xc0\x49\x00\x11\x04", "data": { "calibration": True, "battery": 73, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "{", "modelFriendlyName": "Curtain 3", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=True, ) def test_parse_advertisement_data_curtain3_passive(): """Test parse_advertisement_data for curtain passive.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xaa\xbb\xcc\xdd\xee\xff\xf7\x07\x00\x11\x04\x00\x49" }, service_data={}, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.CURTAIN) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": None, "data": { "calibration": None, "battery": 73, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=False, ) def test_parse_advertisement_data_contact(): """Test parse_advertisement_data for the contact sensor.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xe7\xabF\xac\x8f\x92|\x0f\x00\x11\x04"}, service_data={ "0000fd3d-0000-1000-8000-00805f9b34fb": b"d@d\x05\x00u\x00\xf8\x12" }, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"d@d\x05\x00u\x00\xf8\x12", "data": { "button_count": 2, "contact_open": True, "contact_timeout": True, "is_light": True, "battery": 100, "motion_detected": True, "tested": False, }, "isEncrypted": False, "model": "d", "modelFriendlyName": "Contact Sensor", "modelName": SwitchbotModel.CONTACT_SENSOR, }, device=ble_device, rssi=-80, active=True, ) def test_parse_advertisement_data_empty(): """Test parse_advertisement_data with empty data does not blow up.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2403: b"\xe7\xabF\xac\x8f\x92|\x0f\x00\x11\x04"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b""}, ) result = parse_advertisement_data(ble_device, adv_data) assert result is None def test_new_bot_firmware(): """Test parsing adv data from new bot firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10\xe1"}, service_uuids=["CBA20D00-224D-11E6-9FB8-0002A5D5C51B"], rssi=-90, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"H\x10\xe1", "data": {"switchMode": False, "isOn": False, "battery": 97}, "model": "H", "isEncrypted": False, "modelFriendlyName": "Bot", "modelName": SwitchbotModel.BOT, }, device=ble_device, rssi=-90, active=True, ) def test_parse_advertisement_data_curtain_firmware_six_fully_closed(): """Test parse_advertisement_data with firmware six fully closed.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={ 89: b"\xcc\xf4\xc4\xf9\xacl", 2409: b"\xcc\xf4\xc4\xf9\xacl\xeb\x0fd\x12\x04", }, service_data={ "00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Yd\x11\x04", "0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0dd\x12\x04", }, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=-2, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0dd\x12\x04", "data": { "calibration": True, "battery": 100, "inMotion": False, "position": 0, "lightLevel": 1, "deviceChain": 2, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-2, active=True, ) def test_parse_advertisement_data_curtain_firmware_six_fully_open(): """Test parse_advertisement_data with firmware six fully open.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( local_name="WoCurtain", manufacturer_data={ 89: b"\xcc\xf4\xc4\xf9\xacl", 2409: b"\xcc\xf4\xc4\xf9\xacl\xe2\x0f\x00\x12\x04", }, service_data={ "00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Yd\x11\x04", "0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0d\x00\x12\x04", }, service_uuids=[ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", ], rssi=-2, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0d\x00\x12\x04", "data": { "calibration": True, "battery": 100, "inMotion": False, "position": 100, "lightLevel": 1, "deviceChain": 2, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-2, active=True, ) def test_contact_sensor_mfr(): """Test parsing adv data from new bot firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xcb9\xcd\xc4=FA,\x00F\x01\x8f\xc4"}, service_data={ "0000fd3d-0000-1000-8000-00805f9b34fb": b"d\x00\xda\x04\x00F\x01\x8f\xc4" }, tx_power=-127, rssi=-70, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 90, "button_count": 4, "contact_open": True, "contact_timeout": True, "is_light": False, "motion_detected": False, "tested": False, }, "isEncrypted": False, "model": "d", "modelFriendlyName": "Contact Sensor", "modelName": SwitchbotModel.CONTACT_SENSOR, "rawAdvData": b"d\x00\xda\x04\x00F\x01\x8f\xc4", }, device=ble_device, rssi=-70, active=True, ) def test_contact_sensor_mfr_no_service_data(): """Test contact sensor with passive data only.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xcb9\xcd\xc4=FA,\x00F\x01\x8f\xc4"}, service_data={}, tx_power=-127, rssi=-70, ) result = parse_advertisement_data(ble_device, adv_data) # Passive detection of contact sensor is not supported # anymore since the Switchbot Curtain v3 was released # which uses the heuristics for the contact sensor. assert result is None def test_contact_sensor_srv(): """Test parsing adv data from new bot firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( service_data={ "0000fd3d-0000-1000-8000-00805f9b34fb": b"d\x00\xda\x04\x00F\x01\x8f\xc4" }, tx_power=-127, rssi=-70, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 90, "button_count": 4, "contact_open": True, "contact_timeout": True, "is_light": False, "motion_detected": False, "tested": False, }, "isEncrypted": False, "model": "d", "modelFriendlyName": "Contact Sensor", "modelName": SwitchbotModel.CONTACT_SENSOR, "rawAdvData": b"d\x00\xda\x04\x00F\x01\x8f\xc4", }, device=ble_device, rssi=-70, active=True, ) def test_contact_sensor_open(): """Test parsing mfr adv data from new bot firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xcb9\xcd\xc4=F\x84\x9c\x00\x17\x00QD"}, service_data={ "0000fd3d-0000-1000-8000-00805f9b34fb": b"d@\xda\x02\x00\x17\x00QD" }, tx_power=-127, rssi=-59, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 90, "button_count": 4, "contact_open": True, "contact_timeout": False, "is_light": False, "motion_detected": True, "tested": False, }, "isEncrypted": False, "model": "d", "modelFriendlyName": "Contact Sensor", "modelName": SwitchbotModel.CONTACT_SENSOR, "rawAdvData": b"d@\xda\x02\x00\x17\x00QD", }, device=ble_device, rssi=-59, active=True, ) def test_contact_sensor_closed(): """Test parsing mfr adv data from new bot firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xcb9\xcd\xc4=F\x89\x8c\x00+\x00\x19\x84"}, service_data={ "0000fd3d-0000-1000-8000-00805f9b34fb": b"d@\xda\x00\x00+\x00\x19\x84" }, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 90, "button_count": 4, "contact_open": False, "contact_timeout": False, "is_light": False, "motion_detected": True, "tested": False, }, "isEncrypted": False, "model": "d", "modelFriendlyName": "Contact Sensor", "modelName": SwitchbotModel.CONTACT_SENSOR, "rawAdvData": b"d@\xda\x00\x00+\x00\x19\x84", }, device=ble_device, rssi=-50, active=True, ) def test_switchbot_passive(): """Test parsing switchbot as passive.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={89: bytes.fromhex("d51cfb397856")}, service_data={}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.BOT) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "switchMode": None, "isOn": None, }, "isEncrypted": False, "model": "H", "modelFriendlyName": "Bot", "modelName": SwitchbotModel.BOT, "rawAdvData": None, }, device=ble_device, rssi=-50, active=False, ) def test_bulb_active(): """Test parsing bulb as active.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\x84\xf7\x03\xb4\xcbz\x03\xe4!\x00\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d"}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "brightness": 100, "color_mode": 1, "delay": False, "isOn": True, "loop_index": 0, "preset": False, "sequence_number": 3, "speed": 0, }, "isEncrypted": False, "model": "u", "modelFriendlyName": "Color Bulb", "modelName": SwitchbotModel.COLOR_BULB, "rawAdvData": b"u\x00d", }, device=ble_device, rssi=-50, active=True, ) def test_wosensor_passive_and_active(): """Test parsing wosensor as passive with active data as well.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xd7\xc1}]\xebC\xde\x03\x06\x985"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00\xe4\x06\x985"}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 100, "fahrenheit": False, "humidity": 53, "temp": {"c": 24.6, "f": 76.28}, "temperature": 24.6, }, "isEncrypted": False, "model": "T", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER, "rawAdvData": b"T\x00\xe4\x06\x985", }, device=ble_device, rssi=-50, active=True, ) def test_wosensor_active(): """Test parsing wosensor with active data as well.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00\xe4\x06\x985"}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 100, "fahrenheit": False, "humidity": 53, "temp": {"c": 24.6, "f": 76.28}, "temperature": 24.6, }, "isEncrypted": False, "model": "T", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER, "rawAdvData": b"T\x00\xe4\x06\x985", }, device=ble_device, rssi=-50, active=True, ) def test_wosensor_passive_only(): """Test parsing wosensor with only passive data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xd7\xc1}]\xebC\xde\x03\x06\x985"}, service_data={}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.METER) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "fahrenheit": False, "humidity": 53, "temp": {"c": 24.6, "f": 76.28}, "temperature": 24.6, }, "isEncrypted": False, "model": "T", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER, "rawAdvData": None, }, device=ble_device, rssi=-50, active=False, ) def test_wosensor_active_zero_data(): """Test parsing wosensor with active data but all values are zero.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00\x00\x00\x00\x00"}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": {}, "isEncrypted": False, "model": "T", "rawAdvData": b"T\x00\x00\x00\x00\x00", }, device=ble_device, rssi=-50, active=True, ) def test_wohub2_passive_and_active(): """Test parsing wosensor as passive with active data as well.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\xfffT\x1a\xf1\x82\x07\x9a2\x00" }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "fahrenheit": False, "humidity": 50, "lightLevel": 2, "temp": {"c": 26.7, "f": 80.06}, "temperature": 26.7, }, "isEncrypted": False, "model": "v", "modelFriendlyName": "Hub 2", "modelName": SwitchbotModel.HUB2, "rawAdvData": b"v\x00", }, device=ble_device, rssi=-50, active=True, ) def test_woiosensor_passive_and_active(): """Test parsing woiosensor as passive with active data as well.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xaa\xbb\xcc\xdd\xee\xff\xe0\x0f\x06\x985\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"w\x00\xe4"}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 100, "fahrenheit": False, "humidity": 53, "temp": {"c": 24.6, "f": 76.28}, "temperature": 24.6, }, "isEncrypted": False, "model": "w", "modelFriendlyName": "Indoor/Outdoor Meter", "modelName": SwitchbotModel.IO_METER, "rawAdvData": b"w\x00\xe4", }, device=ble_device, rssi=-50, active=True, ) def test_woiosensor_passive_only(): """Test parsing woiosensor with only passive data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xaa\xbb\xcc\xdd\xee\xff\xe0\x0f\x06\x985\x00"}, service_data={}, tx_power=-127, rssi=-50, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.IO_METER) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "fahrenheit": False, "humidity": 53, "temp": {"c": 24.6, "f": 76.28}, "temperature": 24.6, }, "isEncrypted": False, "model": "w", "modelFriendlyName": "Indoor/Outdoor Meter", "modelName": SwitchbotModel.IO_METER, "rawAdvData": None, }, device=ble_device, rssi=-50, active=False, ) def test_motion_sensor_clear(): """Test parsing motion sensor with clear data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc0!\x9a\xe8\xbcIj\x1c\x00f"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"s\x00\xe2\x00f\x01"}, tx_power=-127, rssi=-87, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 98, "iot": 0, "is_light": False, "led": 0, "light_intensity": 1, "motion_detected": False, "sense_distance": 0, "tested": False, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": b"s\x00\xe2\x00f\x01", }, device=ble_device, rssi=-87, active=True, ) def test_motion_sensor_clear_passive(): """Test parsing motion sensor with clear data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc0!\x9a\xe8\xbcIj\x1c\x00f"}, service_data={}, tx_power=-127, rssi=-87, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "iot": None, "is_light": False, "led": None, "light_intensity": None, "motion_detected": False, "sense_distance": None, "tested": None, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": None, }, device=ble_device, rssi=-87, active=False, ) def test_motion_sensor_motion(): """Test parsing motion sensor with motion data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc0!\x9a\xe8\xbcIi\\\x008"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"s@\xe2\x008\x01"}, tx_power=-127, rssi=-87, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 98, "iot": 0, "is_light": False, "led": 0, "light_intensity": 1, "motion_detected": True, "sense_distance": 0, "tested": False, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": b"s@\xe2\x008\x01", }, device=ble_device, rssi=-87, active=True, ) def test_motion_sensor_motion_passive(): """Test parsing motion sensor with motion data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc0!\x9a\xe8\xbcIi\\\x008"}, service_data={}, tx_power=-127, rssi=-87, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "iot": None, "is_light": False, "led": None, "light_intensity": None, "motion_detected": True, "sense_distance": None, "tested": None, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": None, }, device=ble_device, rssi=-87, active=False, ) def test_motion_sensor_is_light_passive(): """Test parsing motion sensor with motion data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc0!\x9a\xe8\xbcIs,\x04g"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"s\x00\xe2\x04g\x02"}, tx_power=-127, rssi=-93, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 98, "iot": 0, "is_light": True, "led": 0, "light_intensity": 2, "motion_detected": False, "sense_distance": 0, "tested": False, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": b"s\x00\xe2\x04g\x02", }, device=ble_device, rssi=-93, active=True, ) def test_motion_sensor_is_light_active(): """Test parsing motion sensor with motion data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"s\x00\xe2\x04g\x02"}, tx_power=-127, rssi=-93, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 98, "iot": 0, "is_light": True, "led": 0, "light_intensity": 2, "motion_detected": False, "sense_distance": 0, "tested": False, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": b"s\x00\xe2\x04g\x02", }, device=ble_device, rssi=-93, active=True, ) def test_motion_with_light_detected(): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc0!\x9a\xe8\xbcIvl\x00,"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"s@\xe2\x00,\x02"}, tx_power=-127, rssi=-84, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.MOTION_SENSOR ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 98, "iot": 0, "is_light": True, "led": 0, "light_intensity": 2, "motion_detected": True, "sense_distance": 0, "tested": False, }, "isEncrypted": False, "model": "s", "modelFriendlyName": "Motion Sensor", "modelName": SwitchbotModel.MOTION_SENSOR, "rawAdvData": b"s@\xe2\x00,\x02", }, device=ble_device, rssi=-84, active=True, ) def test_parsing_lock_active(): """Test parsing lock with active data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00 "}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "auto_lock_paused": False, "battery": 100, "calibration": True, "door_open": False, "double_lock_mode": False, "night_latch": False, "status": LockStatus.LOCKED, "unclosed_alarm": False, "unlocked_alarm": False, "update_from_secondary_lock": False, }, "isEncrypted": False, "model": "o", "modelFriendlyName": "Lock", "modelName": SwitchbotModel.LOCK, "rawAdvData": b"o\x80d", }, device=ble_device, rssi=-67, active=True, ) def test_parsing_lock_passive(): """Test parsing lock with active data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00 "}, rssi=-67 ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "auto_lock_paused": False, "battery": None, "calibration": True, "door_open": False, "double_lock_mode": False, "night_latch": False, "status": LockStatus.LOCKED, "unclosed_alarm": False, "unlocked_alarm": False, "update_from_secondary_lock": False, }, "isEncrypted": False, "model": "o", "modelFriendlyName": "Lock", "modelName": SwitchbotModel.LOCK, "rawAdvData": None, }, device=ble_device, rssi=-67, active=False, ) def test_parsing_lock_pro_active(): """Test parsing lock pro with active data.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc8\xf5,\xd9-V\x07\x82\x00d\x00\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"$\x80d"}, rssi=-80, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK_PRO) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 100, "calibration": True, "status": LockStatus.LOCKED, "update_from_secondary_lock": False, "door_open": False, "double_lock_mode": False, "unclosed_alarm": False, "unlocked_alarm": False, "auto_lock_paused": False, "night_latch": False, }, "model": "$", "isEncrypted": False, "modelFriendlyName": "Lock Pro", "modelName": SwitchbotModel.LOCK_PRO, "rawAdvData": b"$\x80d", }, device=ble_device, rssi=-80, active=True, ) def test_parsing_lock_pro_passive(): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: bytes.fromhex("aabbccddeeff208200640000")}, rssi=-67 ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK_PRO) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "calibration": True, "status": LockStatus.LOCKED, "update_from_secondary_lock": False, "door_open": False, "double_lock_mode": False, "unclosed_alarm": False, "unlocked_alarm": False, "auto_lock_paused": False, "night_latch": False, }, "model": "$", "isEncrypted": False, "modelFriendlyName": "Lock Pro", "modelName": SwitchbotModel.LOCK_PRO, "rawAdvData": None, }, device=ble_device, rssi=-67, active=False, ) def test_parsing_lock_pro_passive_nightlatch_disabled(): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: bytes.fromhex("aabbccddeeff0a8200630000")}, rssi=-67 ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK_PRO) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "calibration": True, "status": LockStatus.LOCKED, "update_from_secondary_lock": False, "door_open": False, "double_lock_mode": False, "unclosed_alarm": False, "unlocked_alarm": False, "auto_lock_paused": False, "night_latch": False, }, "model": "$", "isEncrypted": False, "modelFriendlyName": "Lock Pro", "modelName": SwitchbotModel.LOCK_PRO, "rawAdvData": None, }, device=ble_device, rssi=-67, active=False, ) def test_parsing_lock_active_old_firmware(): """Test parsing lock with active data. Old firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "auto_lock_paused": False, "battery": 100, "calibration": True, "door_open": False, "double_lock_mode": False, "night_latch": False, "status": LockStatus.LOCKED, "unclosed_alarm": False, "unlocked_alarm": False, "update_from_secondary_lock": False, }, "isEncrypted": False, "model": "o", "modelFriendlyName": "Lock", "modelName": SwitchbotModel.LOCK, "rawAdvData": b"o\x80d", }, device=ble_device, rssi=-67, active=True, ) def test_parsing_lock_passive_old_firmware(): """Test parsing lock with active data. Old firmware.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xf1\t\x9fE\x1a]\x07\x83\x00"}, rssi=-67 ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LOCK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "auto_lock_paused": False, "battery": None, "calibration": True, "door_open": False, "double_lock_mode": False, "night_latch": False, "status": LockStatus.LOCKED, "unclosed_alarm": False, "unlocked_alarm": False, "update_from_secondary_lock": False, }, "isEncrypted": False, "model": "o", "modelFriendlyName": "Lock", "modelName": SwitchbotModel.LOCK, "rawAdvData": None, }, device=ble_device, rssi=-67, active=False, ) def test_meter_pro_active() -> None: ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xb0\xe9\xfeR\xdd\x84\x06d\x08\x97,\x00\x05"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"4\x00d"}, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 100, "fahrenheit": False, "humidity": 44, "temp": {"c": 23.8, "f": 74.84}, "temperature": 23.8, }, "isEncrypted": False, "model": "4", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER_PRO, "rawAdvData": b"4\x00d", }, device=ble_device, rssi=-67, active=True, ) def test_meter_pro_passive() -> None: ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xb0\xe9\xfeR\xdd\x84\x06d\x08\x97,\x00\x05"}, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.METER_PRO) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "fahrenheit": False, "humidity": 44, "temp": {"c": 23.8, "f": 74.84}, "temperature": 23.8, }, "isEncrypted": False, "model": "4", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER_PRO, "rawAdvData": None, }, device=ble_device, rssi=-67, active=False, ) def test_meter_pro_c_active() -> None: ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xb0\xe9\xfeT2\x15\xb7\xe4\x07\x9b\xa4\x007\x02\xd5\x00" }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"5\x00d"}, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 100, "fahrenheit": True, "humidity": 36, "temp": {"c": 27.7, "f": 81.86}, "temperature": 27.7, "co2": 725, }, "isEncrypted": False, "model": "5", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER_PRO_C, "rawAdvData": b"5\x00d", }, device=ble_device, rssi=-67, active=True, ) def test_meter_pro_c_passive() -> None: ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xb0\xe9\xfeT2\x15\xb7\xe4\x07\x9b\xa4\x007\x02\xd5\x00" }, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.METER_PRO_C) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, "fahrenheit": True, "humidity": 36, "temp": {"c": 27.7, "f": 81.86}, "temperature": 27.7, "co2": 725, }, "isEncrypted": False, "model": "5", "modelFriendlyName": "Meter", "modelName": SwitchbotModel.METER_PRO_C, "rawAdvData": None, }, device=ble_device, rssi=-67, active=False, ) def test_parse_advertisement_data_keypad(): """Test parse_advertisement_data for the keypad.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xeb\x13\x02\xe6#\x0f\x8fd\x00\x00\x00\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"y\x00d"}, rssi=-67, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.KEYPAD) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": {"attempt_state": 143, "battery": 100}, "isEncrypted": False, "model": "y", "modelFriendlyName": "Keypad", "modelName": SwitchbotModel.KEYPAD, "rawAdvData": b"y\x00d", }, device=ble_device, rssi=-67, active=True, ) def test_parse_advertisement_data_relay_switch_1pm(): """Test parse_advertisement_data for the keypad.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"<\x00\x00\x00"}, rssi=-67, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.RELAY_SWITCH_1PM ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "switchMode": True, "sequence_number": 71, "isOn": True, "power": 4.9, "voltage": 0, "current": 0, }, "isEncrypted": False, "model": "<", "modelFriendlyName": "Relay Switch 1PM", "modelName": SwitchbotModel.RELAY_SWITCH_1PM, "rawAdvData": b"<\x00\x00\x00", }, device=ble_device, rssi=-67, active=True, ) def test_parse_advertisement_data_relay_switch_1(): """Test parse_advertisement_data for the keypad.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b";\x00\x00\x00"}, rssi=-67, ) result = parse_advertisement_data( ble_device, adv_data, SwitchbotModel.RELAY_SWITCH_1 ) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "switchMode": True, "sequence_number": 71, "isOn": True, }, "isEncrypted": False, "model": ";", "modelFriendlyName": "Relay Switch 1", "modelName": SwitchbotModel.RELAY_SWITCH_1, "rawAdvData": b";\x00\x00\x00", }, device=ble_device, rssi=-67, active=True, ) def test_leak_active(): """Test parse_advertisement_data for the leak detector.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc4407Lz\x18N\x98g^\x94Q<\x05\x00\x00\x00\x00"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00N"}, rssi=-72, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "leak": False, "tampered": False, "battery": 78, "low_battery": False, }, "isEncrypted": False, "model": "&", "modelFriendlyName": "Leak Detector", "modelName": SwitchbotModel.LEAK, "rawAdvData": b"&\x00N", }, device=ble_device, rssi=-72, active=True, ) def test_leak_passive(): """Test parse_advertisement_data for the leak detector.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={2409: b"\xc4407Lz\x18N\x98g^\x94Q<\x05\x00\x00\x00\x00"}, rssi=-72, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": {}, "isEncrypted": False, "model": "&", "rawAdvData": None, }, device=ble_device, rssi=-72, active=False, ) def test_leak_no_leak_detected(): """Test parse_advertisement_data for the leak detector.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xc4407LzJd\x98ga\xc4\n<\x05\x00\x00\x00\x00" }, # no leak, batt service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00d"}, rssi=-73, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "leak": False, "tampered": False, "battery": 100, "low_battery": False, }, "isEncrypted": False, "model": "&", "modelFriendlyName": "Leak Detector", "modelName": SwitchbotModel.LEAK, "rawAdvData": b"&\x00d", }, device=ble_device, rssi=-73, active=True, ) def test_leak_leak_detected(): """Test parse_advertisement_data for the leak detector.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xc4407LzGd\xf9ga\xc4\x08<\x05\x00\x00\x00\x00" }, # leak, batt service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00d"}, rssi=-73, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "leak": True, "tampered": False, "battery": 100, "low_battery": False, }, "isEncrypted": False, "model": "&", "modelFriendlyName": "Leak Detector", "modelName": SwitchbotModel.LEAK, "rawAdvData": b"&\x00d", }, device=ble_device, rssi=-73, active=True, ) def test_leak_low_battery(): """Test parse_advertisement_data for the leak detector.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\xc4407Lz\x02\t\x98\x00\x00\x00\x00<\x05\x00\x00\x00\x00" }, # no leak, low battery service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\x00d"}, rssi=-73, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "leak": False, "tampered": False, "battery": 9, "low_battery": False, }, "isEncrypted": False, "model": "&", "modelFriendlyName": "Leak Detector", "modelName": SwitchbotModel.LEAK, "rawAdvData": b"&\x00d", }, device=ble_device, rssi=-73, active=True, ) def test_leak_real_data_from_ha(): """Test parse_advertisement_data for the leak detector.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Any") adv_data = generate_advertisement_data( manufacturer_data={ 2409: b"\\xd6407D1\\x02V\\x90\\x00\\x00\\x00\\x00\\x1e\\x05\\x00\\x00\\x00\\x00" }, # no leak, low battery service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"&\\x00V"}, rssi=-73, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.LEAK) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "leak": True, "tampered": False, "battery": 68, "low_battery": False, }, "isEncrypted": False, "model": "&", "modelFriendlyName": "Leak Detector", "modelName": SwitchbotModel.LEAK, "rawAdvData": b"&\\x00V", }, device=ble_device, rssi=-73, active=True, ) def test_remote_active() -> None: ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], rssi=-95, ) result = parse_advertisement_data(ble_device, adv_data) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": 86, }, "isEncrypted": False, "model": "b", "modelFriendlyName": "Remote", "modelName": SwitchbotModel.REMOTE, "rawAdvData": b"b V\x00", }, device=ble_device, rssi=-95, active=True, ) def test_remote_passive() -> None: ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") adv_data = generate_advertisement_data( manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, rssi=-97, ) result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.REMOTE) assert result == SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "data": { "battery": None, }, "isEncrypted": False, "model": "b", "modelFriendlyName": "Remote", "modelName": SwitchbotModel.REMOTE, "rawAdvData": None, }, device=ble_device, rssi=-97, active=False, ) pySwitchbot-0.57.1/tests/test_base_cover.py000066400000000000000000000122231476535110100210110ustar00rootroot00000000000000from unittest.mock import AsyncMock, Mock import pytest from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.devices import base_cover, blind_tilt from .test_adv_parser import generate_ble_device def create_device_for_command_testing(position=50, calibration=True): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") base_cover_device = base_cover.SwitchbotBaseCover(False, ble_device) base_cover_device.update_from_advertisement( make_advertisement_data(ble_device, True, position, calibration) ) base_cover_device._send_multiple_commands = AsyncMock() base_cover_device.update = AsyncMock() return base_cover_device def make_advertisement_data( ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True ): """Set advertisement data with defaults.""" return SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0X\x00\x11\x04", "data": { "calibration": calibration, "battery": 88, "inMotion": in_motion, "tilt": position, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=True, ) @pytest.mark.asyncio async def test_send_multiple_commands(): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") base_cover_device = base_cover.SwitchbotBaseCover(False, ble_device) base_cover_device.update_from_advertisement( make_advertisement_data(ble_device, True, 50, True) ) base_cover_device._send_command = AsyncMock() base_cover_device._check_command_result = Mock(return_value=True) await base_cover_device._send_multiple_commands(blind_tilt.OPEN_KEYS) assert base_cover_device._send_command.await_count == 2 @pytest.mark.asyncio async def test_stop(): base_cover_device = create_device_for_command_testing() await base_cover_device.stop() base_cover_device._send_multiple_commands.assert_awaited_once_with( base_cover.STOP_KEYS ) @pytest.mark.asyncio async def test_set_position(): base_cover_device = create_device_for_command_testing() await base_cover_device.set_position(50) base_cover_device._send_multiple_commands.assert_awaited_once() @pytest.mark.asyncio @pytest.mark.parametrize("data_value", [(None), (b"\x07"), (b"\x00")]) async def test_get_extended_info_adv_returns_none_when_bad_data(data_value): base_cover_device = create_device_for_command_testing() base_cover_device._send_command = AsyncMock(return_value=data_value) assert await base_cover_device.get_extended_info_adv() is None @pytest.mark.asyncio async def test_get_extended_info_adv_returns_single_device(): base_cover_device = create_device_for_command_testing() base_cover_device._send_command = AsyncMock( return_value=bytes([0, 50, 20, 0, 0, 0, 0]) ) ext_result = await base_cover_device.get_extended_info_adv() assert ext_result["device0"]["battery"] == 50 assert ext_result["device0"]["firmware"] == 2 assert "device1" not in ext_result @pytest.mark.asyncio async def test_get_extended_info_adv_returns_both_devices(): base_cover_device = create_device_for_command_testing() base_cover_device._send_command = AsyncMock( return_value=bytes([0, 50, 20, 0, 10, 30, 0]) ) ext_result = await base_cover_device.get_extended_info_adv() assert ext_result["device0"]["battery"] == 50 assert ext_result["device0"]["firmware"] == 2 assert ext_result["device1"]["battery"] == 10 assert ext_result["device1"]["firmware"] == 3 @pytest.mark.asyncio @pytest.mark.parametrize( "data_value,result", [ (0, "not_charging"), (1, "charging_by_adapter"), (2, "charging_by_solar"), (3, "fully_charged"), (4, "solar_not_charging"), (5, "charging_error"), ], ) async def test_get_extended_info_adv_returns_device0_charge_states(data_value, result): base_cover_device = create_device_for_command_testing() base_cover_device._send_command = AsyncMock( return_value=bytes([0, 50, 20, data_value, 10, 30, 0]) ) ext_result = await base_cover_device.get_extended_info_adv() assert ext_result["device0"]["stateOfCharge"] == result @pytest.mark.asyncio @pytest.mark.parametrize( "data_value,result", [ (0, "not_charging"), (1, "charging_by_adapter"), (2, "charging_by_solar"), (3, "fully_charged"), (4, "solar_not_charging"), (5, "charging_error"), ], ) async def test_get_extended_info_adv_returns_device1_charge_states(data_value, result): base_cover_device = create_device_for_command_testing() base_cover_device._send_command = AsyncMock( return_value=bytes([0, 50, 20, 0, 10, 30, data_value]) ) ext_result = await base_cover_device.get_extended_info_adv() assert ext_result["device1"]["stateOfCharge"] == result pySwitchbot-0.57.1/tests/test_blind_tilt.py000066400000000000000000000200061476535110100210230ustar00rootroot00000000000000from unittest.mock import AsyncMock import pytest from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.devices import blind_tilt from switchbot.devices.base_cover import COVER_EXT_SUM_KEY from .test_adv_parser import generate_ble_device def create_device_for_command_testing( position=50, calibration=True, reverse_mode=False ): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = blind_tilt.SwitchbotBlindTilt( ble_device, reverse_mode=reverse_mode ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, position, calibration) ) curtain_device._send_multiple_commands = AsyncMock() curtain_device.update = AsyncMock() return curtain_device def make_advertisement_data( ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True ): """Set advertisement data with defaults.""" return SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0X\x00\x11\x04", "data": { "calibration": calibration, "battery": 88, "inMotion": in_motion, "tilt": position, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=True, ) @pytest.mark.asyncio async def test_open(): blind_device = create_device_for_command_testing() await blind_device.open() blind_device._send_multiple_commands.assert_awaited_once_with(blind_tilt.OPEN_KEYS) @pytest.mark.asyncio @pytest.mark.parametrize( "position,keys", [(5, blind_tilt.CLOSE_DOWN_KEYS), (55, blind_tilt.CLOSE_UP_KEYS)] ) async def test_close(position, keys): blind_device = create_device_for_command_testing(position=position) await blind_device.close() blind_device._send_multiple_commands.assert_awaited_once_with(keys) @pytest.mark.asyncio async def test_get_basic_info_returns_none_when_no_data(): blind_device = create_device_for_command_testing() blind_device._get_basic_info = AsyncMock(return_value=None) assert await blind_device.get_basic_info() is None @pytest.mark.asyncio @pytest.mark.parametrize( "reverse_mode,data,result", [ ( False, bytes([0, 1, 10, 2, 255, 255, 50, 4]), [1, 1, 1, 1, 1, True, False, False, True, 50, 4], ), ( False, bytes([0, 1, 10, 2, 0, 0, 50, 4]), [1, 1, 0, 0, 0, False, False, False, False, 50, 4], ), ( False, bytes([0, 1, 10, 2, 0, 1, 50, 4]), [1, 1, 0, 0, 1, False, True, False, True, 50, 4], ), ( True, bytes([0, 1, 10, 2, 255, 255, 50, 4]), [1, 1, 1, 1, 1, True, False, True, False, 50, 4], ), ( True, bytes([0, 1, 10, 2, 0, 0, 50, 4]), [1, 1, 0, 0, 0, False, False, False, False, 50, 4], ), ( True, bytes([0, 1, 10, 2, 0, 1, 50, 4]), [1, 1, 0, 0, 1, False, True, False, True, 50, 4], ), ], ) async def test_get_basic_info(reverse_mode, data, result): blind_device = create_device_for_command_testing(reverse_mode=reverse_mode) blind_device._get_basic_info = AsyncMock(return_value=data) info = await blind_device.get_basic_info() assert info["battery"] == result[0] assert info["firmware"] == result[1] assert info["light"] == result[2] assert info["fault"] == result[2] assert info["solarPanel"] == result[3] assert info["calibration"] == result[3] assert info["calibrated"] == result[3] assert info["inMotion"] == result[4] assert info["motionDirection"]["opening"] == result[5] assert info["motionDirection"]["closing"] == result[6] assert info["motionDirection"]["up"] == result[7] assert info["motionDirection"]["down"] == result[8] assert info["tilt"] == result[9] assert info["timers"] == result[10] @pytest.mark.asyncio async def test_get_extended_info_summary_sends_command(): blind_device = create_device_for_command_testing() blind_device._send_command = AsyncMock() await blind_device.get_extended_info_summary() blind_device._send_command.assert_awaited_once_with(key=COVER_EXT_SUM_KEY) @pytest.mark.asyncio @pytest.mark.parametrize("data_value", [(None), (b"\x07"), (b"\x00")]) async def test_get_extended_info_summary_returns_none_when_bad_data(data_value): blind_device = create_device_for_command_testing() blind_device._send_command = AsyncMock(return_value=data_value) assert await blind_device.get_extended_info_summary() is None @pytest.mark.asyncio @pytest.mark.parametrize( "data,result", [(bytes([0, 0]), False), (bytes([0, 255]), True)] ) async def test_get_extended_info_summary(data, result): blind_device = create_device_for_command_testing() blind_device._send_command = AsyncMock(return_value=data) ext_result = await blind_device.get_extended_info_summary() assert ext_result["device0"]["light"] == result @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_opening(reverse_mode): """Test passive opening advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = blind_tilt.SwitchbotBlindTilt( ble_device, reverse_mode=reverse_mode ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 10) ) assert curtain_device.is_opening() is True assert curtain_device.is_closing() is False @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_closing(reverse_mode): """Test passive closing advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = blind_tilt.SwitchbotBlindTilt( ble_device, reverse_mode=reverse_mode ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 100) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 90) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is True @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_opening_then_stop(reverse_mode): """Test passive stopped after opening advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = blind_tilt.SwitchbotBlindTilt( ble_device, reverse_mode=reverse_mode ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 10) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, False, 10) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_closing_then_stop(reverse_mode): """Test passive stopped after closing advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = blind_tilt.SwitchbotBlindTilt( ble_device, reverse_mode=reverse_mode ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 100) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 90) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, False, 90) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False pySwitchbot-0.57.1/tests/test_curtain.py000066400000000000000000000352521476535110100203550ustar00rootroot00000000000000from unittest.mock import AsyncMock, Mock import pytest from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.devices import curtain from switchbot.devices.base_cover import COVER_EXT_SUM_KEY from .test_adv_parser import generate_ble_device def create_device_for_command_testing(calibration=True, reverse_mode=False): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 50, calibration) ) curtain_device._send_multiple_commands = AsyncMock() curtain_device.update = AsyncMock() return curtain_device def make_advertisement_data( ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True ): """Set advertisement data with defaults.""" return SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"c\xc0X\x00\x11\x04", "data": { "calibration": calibration, "battery": 88, "inMotion": in_motion, "position": position, "lightLevel": 1, "deviceChain": 1, }, "isEncrypted": False, "model": "c", "modelFriendlyName": "Curtain", "modelName": SwitchbotModel.CURTAIN, }, device=ble_device, rssi=-80, active=True, ) @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_not_in_motion(reverse_mode): """Test passive not in motion advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, False, 0) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_opening(reverse_mode): """Test passive opening advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 10) ) assert curtain_device.is_opening() is True assert curtain_device.is_closing() is False @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_closing(reverse_mode): """Test passive closing advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 100) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 90) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is True @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_opening_then_stop(reverse_mode): """Test passive stopped after opening advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 10) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, False, 10) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_device_passive_closing_then_stop(reverse_mode): """Test passive stopped after closing advertisement.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 100) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 90) ) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, False, 90) ) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.asyncio @pytest.mark.parametrize("reverse_mode", [(True), (False)]) async def test_device_active_not_in_motion(reverse_mode): """Test active not in motion.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, False, 0) ) basic_info = bytes([0, 0, 0, 0, 0, 0, 100, 0]) async def custom_implementation(): return basic_info curtain_device._get_basic_info = Mock(side_effect=custom_implementation) await curtain_device.get_basic_info() assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.asyncio @pytest.mark.parametrize("reverse_mode", [(True), (False)]) async def test_device_active_opening(reverse_mode): """Test active opening.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) basic_info = bytes([0, 0, 0, 0, 0, 67, 10, 0]) async def custom_implementation(): return basic_info curtain_device._get_basic_info = Mock(side_effect=custom_implementation) await curtain_device.get_basic_info() assert curtain_device.is_opening() is True assert curtain_device.is_closing() is False @pytest.mark.asyncio @pytest.mark.parametrize("reverse_mode", [(True), (False)]) async def test_device_active_closing(reverse_mode): """Test active closing.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 100) ) basic_info = bytes([0, 0, 0, 0, 0, 67, 90, 0]) async def custom_implementation(): return basic_info curtain_device._get_basic_info = Mock(side_effect=custom_implementation) await curtain_device.get_basic_info() assert curtain_device.is_opening() is False assert curtain_device.is_closing() is True @pytest.mark.asyncio @pytest.mark.parametrize("reverse_mode", [(True), (False)]) async def test_device_active_opening_then_stop(reverse_mode): """Test active stopped after opening.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) basic_info = bytes([0, 0, 0, 0, 0, 67, 10, 0]) async def custom_implementation(): return basic_info curtain_device._get_basic_info = Mock(side_effect=custom_implementation) await curtain_device.get_basic_info() basic_info = bytes([0, 0, 0, 0, 0, 0, 10, 0]) await curtain_device.get_basic_info() assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.asyncio @pytest.mark.parametrize("reverse_mode", [(True), (False)]) async def test_device_active_closing_then_stop(reverse_mode): """Test active stopped after closing.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device, reverse_mode=reverse_mode) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 100) ) basic_info = bytes([0, 0, 0, 0, 0, 67, 90, 0]) async def custom_implementation(): return basic_info curtain_device._get_basic_info = Mock(side_effect=custom_implementation) await curtain_device.get_basic_info() basic_info = bytes([0, 0, 0, 0, 0, 0, 90, 0]) await curtain_device.get_basic_info() assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False @pytest.mark.asyncio async def test_get_basic_info_returns_none_when_no_data(): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) curtain_device._get_basic_info = AsyncMock(return_value=None) assert await curtain_device.get_basic_info() is None @pytest.mark.asyncio @pytest.mark.parametrize( "data,result", [ ( bytes([0, 1, 10, 2, 255, 255, 50, 4]), [1, 1, 2, "right_to_left", 1, 1, 50, 4], ), (bytes([0, 1, 10, 2, 0, 0, 50, 4]), [1, 1, 2, "left_to_right", 0, 0, 50, 4]), ], ) async def test_get_basic_info(data, result): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") curtain_device = curtain.SwitchbotCurtain(ble_device) curtain_device.update_from_advertisement( make_advertisement_data(ble_device, True, 0) ) async def custom_implementation(): return data curtain_device._get_basic_info = Mock(side_effect=custom_implementation) info = await curtain_device.get_basic_info() assert info["battery"] == result[0] assert info["firmware"] == result[1] assert info["chainLength"] == result[2] assert info["openDirection"] == result[3] assert info["touchToOpen"] == result[4] assert info["light"] == result[4] assert info["fault"] == result[4] assert info["solarPanel"] == result[5] assert info["calibration"] == result[5] assert info["calibrated"] == result[5] assert info["inMotion"] == result[5] assert info["position"] == result[6] assert info["timers"] == result[7] @pytest.mark.asyncio async def test_open(): curtain_device = create_device_for_command_testing() await curtain_device.open() assert curtain_device.is_opening() is True assert curtain_device.is_closing() is False curtain_device._send_multiple_commands.assert_awaited_once() @pytest.mark.asyncio async def test_close(): curtain_device = create_device_for_command_testing() await curtain_device.close() assert curtain_device.is_opening() is False assert curtain_device.is_closing() is True curtain_device._send_multiple_commands.assert_awaited_once() @pytest.mark.asyncio async def test_stop(): curtain_device = create_device_for_command_testing() await curtain_device.stop() assert curtain_device.is_opening() is False assert curtain_device.is_closing() is False curtain_device._send_multiple_commands.assert_awaited_once() @pytest.mark.asyncio async def test_set_position_opening(): curtain_device = create_device_for_command_testing() await curtain_device.set_position(100) assert curtain_device.is_opening() is True assert curtain_device.is_closing() is False curtain_device._send_multiple_commands.assert_awaited_once() @pytest.mark.asyncio async def test_set_position_closing(): curtain_device = create_device_for_command_testing() await curtain_device.set_position(0) assert curtain_device.is_opening() is False assert curtain_device.is_closing() is True curtain_device._send_multiple_commands.assert_awaited_once() def test_get_position(): curtain_device = create_device_for_command_testing() assert curtain_device.get_position() == 50 @pytest.mark.asyncio async def test_get_extended_info_summary_sends_command(): curtain_device = create_device_for_command_testing() curtain_device._send_command = AsyncMock() await curtain_device.get_extended_info_summary() curtain_device._send_command.assert_awaited_once_with(key=COVER_EXT_SUM_KEY) @pytest.mark.asyncio @pytest.mark.parametrize("data_value", [(None), (b"\x07"), (b"\x00")]) async def test_get_extended_info_summary_returns_none_when_bad_data(data_value): curtain_device = create_device_for_command_testing() curtain_device._send_command = AsyncMock(return_value=data_value) assert await curtain_device.get_extended_info_summary() is None @pytest.mark.asyncio @pytest.mark.parametrize( "data,result", [ ([0, 0, 0], [True, False, False, "right_to_left"]), ([255, 255, 0], [False, True, True, "left_to_right"]), ], ) async def test_get_extended_info_summary_returns_device0(data, result): curtain_device = create_device_for_command_testing() curtain_device._send_command = AsyncMock(return_value=bytes(data)) ext_result = await curtain_device.get_extended_info_summary() assert ext_result["device0"]["openDirectionDefault"] == result[0] assert ext_result["device0"]["touchToOpen"] == result[1] assert ext_result["device0"]["light"] == result[2] assert ext_result["device0"]["openDirection"] == result[3] assert "device1" not in ext_result @pytest.mark.asyncio @pytest.mark.parametrize( "data,result", [ ([0, 0, 1], [True, False, False, "right_to_left"]), ([255, 255, 255], [False, True, True, "left_to_right"]), ], ) async def test_get_extended_info_summary_returns_device1(data, result): curtain_device = create_device_for_command_testing() curtain_device._send_command = AsyncMock(return_value=bytes(data)) ext_result = await curtain_device.get_extended_info_summary() assert ext_result["device1"]["openDirectionDefault"] == result[0] assert ext_result["device1"]["touchToOpen"] == result[1] assert ext_result["device1"]["light"] == result[2] assert ext_result["device1"]["openDirection"] == result[3] def test_get_light_level(): curtain_device = create_device_for_command_testing() assert curtain_device.get_light_level() == 1 @pytest.mark.parametrize("reverse_mode", [(True), (False)]) def test_is_reversed(reverse_mode): curtain_device = create_device_for_command_testing(reverse_mode=reverse_mode) assert curtain_device.is_reversed() == reverse_mode @pytest.mark.parametrize("calibration", [(True), (False)]) def test_is_calibrated(calibration): curtain_device = create_device_for_command_testing(calibration=calibration) assert curtain_device.is_calibrated() == calibration pySwitchbot-0.57.1/tests/test_evaporative_humidifier.py000066400000000000000000000145401476535110100234370ustar00rootroot00000000000000import datetime from unittest.mock import AsyncMock import pytest from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.adv_parsers.humidifier import process_evaporative_humidifier from switchbot.const.evaporative_humidifier import HumidifierMode, HumidifierWaterLevel from switchbot.devices import evaporative_humidifier from .test_adv_parser import generate_ble_device def create_device_for_command_testing(init_data: dict | None = None): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") evaporative_humidifier_device = ( evaporative_humidifier.SwitchbotEvaporativeHumidifier( ble_device, "ff", "ffffffffffffffffffffffffffffffff" ) ) evaporative_humidifier_device.update_from_advertisement( make_advertisement_data(ble_device, init_data) ) return evaporative_humidifier_device def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None): if init_data is None: init_data = {} """Set advertisement data with defaults.""" return SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"#\x00\x00\x15\x1c\x00", "data": { "isOn": False, "mode": None, "target_humidity": None, "child_lock": False, "over_humidify_protection": True, "tank_removed": False, "tilted_alert": False, "filter_missing": False, "humidity": 51, "temperature": 16.8, "filter_run_time": datetime.timedelta(days=3, seconds=57600), "filter_alert": False, "water_level": HumidifierWaterLevel.LOW, } | init_data, "isEncrypted": False, "model": "#", "modelFriendlyName": "Evaporative Humidifier", "modelName": SwitchbotModel.EVAPORATIVE_HUMIDIFIER, }, device=ble_device, rssi=-80, active=True, ) @pytest.mark.asyncio async def test_process_advertisement(): data = process_evaporative_humidifier( b"#\x00\x00\x15\x1c\x00", b"\xd4\x8cIU\x95\xb2\x08\x06\x88\xb3\x90\x81\x00X\x00X2", ) assert data == { "isOn": False, "mode": None, "target_humidity": None, "child_lock": False, "over_humidify_protection": None, "tank_removed": False, "tilted_alert": False, "filter_missing": False, "humidity": 51, "temperature": 16.8, "filter_run_time": datetime.timedelta(days=3, seconds=57600), "filter_alert": False, "water_level": HumidifierWaterLevel.LOW, } @pytest.mark.asyncio async def test_process_advertisement_empty(): data = process_evaporative_humidifier(None, None) assert data == { "isOn": None, "mode": None, "target_humidity": None, "child_lock": None, "over_humidify_protection": None, "tank_removed": None, "tilted_alert": None, "filter_missing": None, "humidity": None, "temperature": None, "filter_run_time": None, "filter_alert": None, "water_level": None, } @pytest.mark.asyncio async def test_turn_on(): device = create_device_for_command_testing({"isOn": False}) device._send_command = AsyncMock(return_value=b"\x01") assert device.is_on() is False await device.turn_on() assert device.is_on() is True @pytest.mark.asyncio async def test_turn_off(): device = create_device_for_command_testing({"isOn": True}) device._send_command = AsyncMock(return_value=b"\x01") assert device.is_on() is True await device.turn_off() assert device.is_on() is False @pytest.mark.asyncio async def test_set_mode(): device = create_device_for_command_testing( {"isOn": True, "mode": HumidifierMode.LOW} ) device._send_command = AsyncMock(return_value=b"\x01") assert device.get_mode() is HumidifierMode.LOW await device.set_mode(HumidifierMode.AUTO) assert device.get_mode() is HumidifierMode.AUTO await device.set_mode(HumidifierMode.TARGET_HUMIDITY, 60) assert device.get_mode() is HumidifierMode.TARGET_HUMIDITY assert device.get_target_humidity() == 60 await device.set_mode(HumidifierMode.DRYING_FILTER) assert device.get_mode() is HumidifierMode.DRYING_FILTER with pytest.raises(ValueError): await device.set_mode(0) with pytest.raises(TypeError): await device.set_mode(HumidifierMode.TARGET_HUMIDITY) @pytest.mark.asyncio async def test_set_child_lock(): device = create_device_for_command_testing({"child_lock": False}) device._send_command = AsyncMock(return_value=b"\x01") assert device.is_child_lock_enabled() is False await device.set_child_lock(True) assert device.is_child_lock_enabled() is True @pytest.mark.asyncio async def test_start_drying_filter(): device = create_device_for_command_testing( {"isOn": True, "mode": HumidifierMode.AUTO} ) device._send_command = AsyncMock(return_value=b"\x01") assert device.get_mode() is HumidifierMode.AUTO await device.start_drying_filter() assert device.get_mode() is HumidifierMode.DRYING_FILTER @pytest.mark.asyncio async def test_stop_drying_filter(): device = create_device_for_command_testing( {"isOn": True, "mode": HumidifierMode.DRYING_FILTER} ) device._send_command = AsyncMock(return_value=b"\x00") assert device.is_on() is True assert device.get_mode() is HumidifierMode.DRYING_FILTER await device.stop_drying_filter() assert device.is_on() is False assert device.get_mode() is None @pytest.mark.asyncio async def test_attributes(): device = create_device_for_command_testing() device._send_command = AsyncMock(return_value=b"\x01") assert device.is_over_humidify_protection_enabled() is True assert device.is_tank_removed() is False assert device.is_filter_missing() is False assert device.is_filter_alert_on() is False assert device.is_tilted_alert_on() is False assert device.get_water_level() is HumidifierWaterLevel.LOW assert device.get_filter_run_time() == datetime.timedelta(days=3, seconds=57600) assert device.get_humidity() == 51 assert device.get_temperature() == 16.8 pySwitchbot-0.57.1/tests/test_relay_switch.py000066400000000000000000000047431476535110100214060ustar00rootroot00000000000000from unittest.mock import AsyncMock import pytest from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.devices import relay_switch from .test_adv_parser import generate_ble_device def create_device_for_command_testing(calibration=True, reverse_mode=False): ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") relay_switch_device = relay_switch.SwitchbotRelaySwitch( ble_device, "ff", "ffffffffffffffffffffffffffffffff" ) relay_switch_device.update_from_advertisement(make_advertisement_data(ble_device)) return relay_switch_device def make_advertisement_data(ble_device: BLEDevice): """Set advertisement data with defaults.""" return SwitchBotAdvertisement( address="aa:bb:cc:dd:ee:ff", data={ "rawAdvData": b"$X|\x0866G\x81\x00\x00\x001\x00\x00\x00\x00", "data": { "switchMode": True, "sequence_number": 71, "isOn": True, "power": 4.9, "voltage": 0, "current": 0, }, "isEncrypted": False, "model": "<", "modelFriendlyName": "Relay Switch 1PM", "modelName": SwitchbotModel.RELAY_SWITCH_1PM, }, device=ble_device, rssi=-80, active=True, ) @pytest.mark.asyncio async def test_turn_on(): relay_switch_device = create_device_for_command_testing() relay_switch_device._send_command = AsyncMock(return_value=b"\x01") await relay_switch_device.turn_on() assert relay_switch_device.is_on() is True @pytest.mark.asyncio async def test_turn_off(): relay_switch_device = create_device_for_command_testing() relay_switch_device._send_command = AsyncMock(return_value=b"\x01") await relay_switch_device.turn_off() assert relay_switch_device.is_on() is False @pytest.mark.asyncio async def test_get_basic_info(): relay_switch_device = create_device_for_command_testing() relay_switch_device._send_command = AsyncMock(return_value=b"\x01\x01") info = await relay_switch_device.get_basic_info() assert info["is_on"] is True relay_switch_device._send_command = AsyncMock(return_value=b"\x01\x00") info = await relay_switch_device.get_basic_info() assert info["is_on"] is False relay_switch_device._send_command = AsyncMock(return_value=b"\x00\x00") info = await relay_switch_device.get_basic_info() assert info is None