├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── pre-release.yml │ └── publish.yml ├── .gitignore ├── .reuse └── dep5 ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSES └── GPL-3.0-only.txt ├── README.md ├── docs ├── index.html ├── search.js ├── wyzeapy.html └── wyzeapy │ ├── const.html │ ├── crypto.html │ ├── exceptions.html │ ├── payload_factory.html │ ├── services.html │ ├── services │ ├── base_service.html │ ├── bulb_service.html │ ├── camera_service.html │ ├── hms_service.html │ ├── lock_service.html │ ├── sensor_service.html │ ├── switch_service.html │ ├── thermostat_service.html │ ├── update_manager.html │ └── wall_switch_service.html │ ├── types.html │ ├── utils.html │ └── wyze_auth_lib.html ├── poetry.lock ├── pyproject.toml ├── scripts └── create_pre_release.sh └── src └── wyzeapy ├── __init__.py ├── const.py ├── crypto.py ├── exceptions.py ├── payload_factory.py ├── services ├── __init__.py ├── base_service.py ├── bulb_service.py ├── camera_service.py ├── hms_service.py ├── lock_service.py ├── sensor_service.py ├── switch_service.py ├── thermostat_service.py ├── update_manager.py └── wall_switch_service.py ├── tests ├── test_bulb_service.py ├── test_camera_service.py ├── test_hms_service.py ├── test_lock_service.py ├── test_sensor_service.py ├── test_switch_service.py ├── test_thermostat_service.py └── test_wall_switch_service.py ├── types.py ├── utils.py └── wyze_auth_lib.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with ‘#’ are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | * @JoshuaMulliken 6 | 7 | # Order is important. The last matching pattern has the most precedence. 8 | # So if a pull request only touches javascript files, only these owners 9 | # will be requested to review. 10 | # *.js @octocat @github/js 11 | 12 | # You can also use email addresses if you prefer. 13 | # docs/* docs@example.com -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # Adapted from https://raw.githubusercontent.com/actions/starter-workflows/main/code-scanning/codeql.yml 2 | name: "CodeQL" 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: [ "main" ] 8 | pull_request: 9 | # The branches below must be a subset of the branches above 10 | branches: [ "main" ] 11 | schedule: 12 | - cron: '0 0 * * *' 13 | 14 | jobs: 15 | analyze: 16 | name: Analyze 17 | # Runner size impacts CodeQL analysis time. To learn more, please see: 18 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 19 | # - https://gh.io/supported-runners-and-hardware-resources 20 | # - https://gh.io/using-larger-runners 21 | # Consider using larger runners for possible analysis time improvements. 22 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 23 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 24 | permissions: 25 | actions: read 26 | contents: read 27 | security-events: write 28 | 29 | strategy: 30 | fail-fast: false 31 | 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 36 | 37 | # Initializes the CodeQL tools for scanning. 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 40 | 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 46 | 47 | # Command-line programs to run using the OS shell. 48 | # See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 49 | 50 | # If the Autobuild fails above, remove it and uncomment the following three lines. 51 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 52 | 53 | # - run: | 54 | # echo "Run, Build Application using script" 55 | # ./location_of_script_within_repo/buildscript.sh 56 | 57 | - name: Perform CodeQL Analysis 58 | uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 59 | 60 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Upload to Test PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - release/* 7 | 8 | jobs: 9 | test-publish: 10 | runs-on: ubuntu-latest 11 | env: 12 | POETRY_HOME: /opt/poetry 13 | steps: 14 | - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 15 | - uses: actions/setup-python@e9aba2c848f5ebd159c070c61ea2c4e2b122355e # v2 16 | - name: Install poetry 17 | run: | 18 | wget https://raw.githubusercontent.com/python-poetry/install.python-poetry.org/d62875fc05fb20062175cd14d19a96dbefa48640/install-poetry.py 19 | python install-poetry.py --version 1.8.3 20 | - name: Install dependencies 21 | run: | 22 | $POETRY_HOME/bin/poetry install 23 | - name: Configure poetry for Test PyPI repository 24 | run: | 25 | $POETRY_HOME/bin/poetry config repositories.test-pypi https://test.pypi.org/legacy/ 26 | $POETRY_HOME/bin/poetry config pypi-token.test-pypi ${{ secrets.TEST_PYPI_TOKEN }} 27 | - name: Build and publish package 28 | run: | 29 | $POETRY_HOME/bin/poetry publish --build -r test-pypi 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | env: 9 | POETRY_HOME: /opt/poetry 10 | steps: 11 | - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 12 | - uses: actions/setup-python@e9aba2c848f5ebd159c070c61ea2c4e2b122355e # v2 13 | - name: Install poetry 14 | run: | 15 | wget https://raw.githubusercontent.com/python-poetry/install.python-poetry.org/d62875fc05fb20062175cd14d19a96dbefa48640/install-poetry.py 16 | python install-poetry.py --version 1.8.3 17 | - name: Install dependencies 18 | run: | 19 | $POETRY_HOME/bin/poetry install 20 | - name: Configure poetry for PyPI repository 21 | run: | 22 | $POETRY_HOME/bin/poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 23 | - name: Build and publish package 24 | run: | 25 | $POETRY_HOME/bin/poetry publish --build 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 Mulliken, LLC 2 | # 3 | # SPDX-License-Identifier: GPL-3.0-only 4 | 5 | /test.py 6 | /.idea/ 7 | /.scannerwork/ 8 | .venv 9 | __pycache__ 10 | dist/ 11 | .DS_Store 12 | .dccache 13 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: wyzeapy 3 | Upstream-Contact: Mulliken LLC 4 | Source: https://github.com/JoshuaMulliken/wyzeapy 5 | 6 | # Sample paragraph, commented out: 7 | # 8 | # Files: src/* 9 | # Copyright: $YEAR $NAME <$CONTACT> 10 | # License: ... 11 | Files: src/* 12 | Copyright: 2021 Mulliken, LLC 13 | License: GPL-3.0-only 14 | 15 | Files: docs/* 16 | Copyright: 2021 Mulliken, LLC 17 | License: GPL-3.0-only 18 | 19 | Files: tests/* 20 | Copyright: 2021 Mulliken, LLC 21 | License: GPL-3.0-only -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sarif-viewer.connectToGithubCodeScanning": "off" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Changelog 8 | All notable changes to this project will be documented in this file. 9 | See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. 10 | 11 | - - - 12 | ## 0.5.0..0.5.26 - 2023-12-08 13 | 14 | ### Bug Fixes 15 | 16 | * Update device UUID extraction logic in BaseService 17 | * Add CodeQL configuration for improved security analysis 18 | * Better handle token expiration (#83) 19 | * Update notification endpoint (#81) 20 | * Update motion properties (#82) 21 | * Fix aiohttp conflict with homeassistant 22 | * Fixed toggles and properties for many camera models 23 | * Fix value error that might occur under certain conditions 24 | * Pass mutex to DeviceUpdater (#64) 25 | * Guard against empty queue in update_manager 26 | * Fix lock querying - update access_token (#57) 27 | * Protect update function in update_manager.py (#54) 28 | * Handle sun match (#49) 29 | * Remove color mode change (#47) 30 | 31 | ### Features 32 | 33 | * Add support for UnLocking and Locking states for Wyze Lock (#75) 34 | * Add support for outdoor plug energy sensors (#79) 35 | * Add API Key/ID support (#70) 36 | * Support for camera sirens (#42) 37 | * Add option to unload entities (#37) 38 | * Add cloud fallback when local control is unavailable (#35) 39 | * Support for lightstrips (#32) 40 | * Local control for color bulbs (#31) 41 | * Support for Floodlight (#45) 42 | * Support music mode (#44) 43 | * Add global local control option (#38) 44 | * Update manager - use a mutex (#60) 45 | * Re-enable and fix thermostat presets (#59) 46 | * Add Camera motion detection switch (#56) 47 | * Handle music mode switch (#55) 48 | * Add new Wyze wall switch (#52) 49 | * Add notification property (#46) 50 | 51 | ### Dependencies 52 | 53 | * Bump aiohttp from 3.8.1 to 3.8.5 (#69) 54 | * Bump aiohttp from 3.10.9 to 3.10.11 55 | * Bump idna from 3.6 to 3.7 (#90) 56 | * Bump aiohttp from 3.9.3 to 3.9.4 (#89) 57 | 58 | ### Continuous Integration 59 | 60 | * Add publish workflow and adjust job names 61 | * Add prerelease github workflow 62 | 63 | ### Miscellaneous Chores 64 | 65 | * Bump patch version 66 | * Update repository name 67 | * Update poetry 68 | * Fix wall switch service compatibility 69 | * Update exceptions handling 70 | 71 | - - - 72 | ## 0.4.4..0.5.0 - 2021-10-06 73 | 74 | 75 | ### Continuous Integration 76 | 77 | 6751e7 - Add Semgrep CI - semgrep.dev on behalf of @JoshuaMulliken 78 | cd937f - add codeql-analysis.yml - Katie Mulliken 79 | 80 | ### Bug Fixes 81 | 82 | 19146d - update manager inconsistencies and infinite loop (#1)* refactor: update_manager: Add some consistency to the updates_per_interval valuechange the name of this argument so that it is more clear that this should be the time between updates. Additionally add a doc string to give some context.This also means that we'll need to calculate the interval/updates_per_interval to get the actual countdown time.* fix: update_manger: fix the infinite loop while trying to conduct a backoffthis infinite loop was created because of an inconsitency in how the updates_per_interval value was being used between the classes. Now that it's being used an intended, the value should be used directly in these values.A smaller updates_per_interval will equate to a longer time between updates for a device. - Joe Schubert 83 | 4e6d3e - ensure that updates_per_interval cannot be reduced to zero - Katie Mulliken 84 | 85 | ### Features 86 | 87 | da4455 - Utilize an update_manager to alleviate load on wyze's api (#1)Co-authored-by: Katie Mulliken - Joe Schubert 88 | 89 | ### Miscellaneous Chores 90 | 91 | a92950 - bump version to stable - Josh Mulliken 92 | 82b588 - bump version - Josh Mulliken 93 | ed7eb5 - bump version - Katie Mulliken 94 | fea63d - correct license information - Josh Mulliken 95 | 96 | - - - 97 | ## 0.4.4 - 2021-09-27 98 | 99 | 100 | ### Bug Fixes 101 | 102 | f08f6b - fix lock status being flipped - Katie Mulliken 103 | 104 | 105 | ### Miscellaneous Chores 106 | 107 | b7cd51 - bump version in setup.cfg - Katie Mulliken 108 | 109 | 110 | - - - 111 | ## 0.4.3 - 2021-09-26 112 | 113 | 114 | ### Bug Fixes 115 | 116 | 88e1bb - modify pypi version number - Katie Mulliken 117 | 118 | 119 | - - - 120 | ## 0.4.2 - 2021-09-26 121 | 122 | 123 | ### Bug Fixes 124 | 125 | 8e26ff - remove redundent return before the logging functions - Katie Mulliken 126 | 127 | 128 | ### Documentation 129 | 130 | 532552 - update changelog to fit what cocogitto expects - Katie Mulliken 131 | 132 | 133 | - - - 134 | ## 0.4.0..0.4.1 - 2021-09-26 135 | 136 | 137 | ### Miscellaneous Chores 138 | 139 | 8c9f7a - cleanup old files - Katie Mulliken 140 | 726a46 - bump version - Katie Mulliken 141 | 142 | ### Features 143 | 144 | 8bff85 - add logging for get, patch, and delete - Katie Mulliken 145 | 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Wyzeapy 8 | 9 | A Python library for the (unofficial) Wyze Labs web APIs. 10 | 11 | ## Used By 12 | 13 | This project is used by the [ha-wyzeapi](https://github.com/SecKatie/ha-wyzeapi) project. Let me know if you are utilizing it so that I can feature your project here! 14 | 15 | ## Usage/Examples 16 | 17 | Getting logged in: 18 | 19 | ```python 20 | import asyncio 21 | from wyzeapy import Wyzeapy 22 | 23 | 24 | async def async_main(): 25 | client = await Wyzeapy.create() 26 | await client.login("EMAIL", "PASSWORD") 27 | 28 | 29 | if __name__ == "__main__": 30 | loop = asyncio.get_event_loop() 31 | loop.run_until_complete(async_main()) 32 | ``` 33 | 34 | ## Thanks to: 35 | 36 | - [@shauntarves](https://github.com/shauntarves): for contributing the App ID and Signing Secret 37 | - [@yoinx](https://github.com/yoinx): for considerable contributions to the project 38 | 39 | 40 | ## License 41 | 42 | [GNU GPLv3](https://choosealicense.com/licenses/gpl-3.0/) 43 | 44 | ## Author 45 | 46 | Developed by Katie Mulliken ([SecKatie](https://github.com/SecKatie)) 47 | 48 | ## Appendix 49 | 50 | ### Documentation 51 | 52 | Docs are generated using [pdoc](https://pdoc.dev/). To generate docs for this project, run: 53 | 54 | ```bash 55 | # Activate the poetry environment (that includes the dev dependencies) 56 | eval "$(poetry env activate)" 57 | 58 | # Generate docs 59 | pdoc --output-dir=docs src/wyzeapy 60 | ``` 61 | 62 | [Full doc reference](https://seckatie.github.io/wyzeapy/) - Please note that I am still working on doc strings so please be patient. 63 | 64 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "wyzeapy" 3 | version = "0.5.28" 4 | description = "A library for interacting with Wyze devices" 5 | authors = ["Katie Mulliken "] 6 | license = "GPL-3.0-only" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=3.11.0" 10 | aiohttp = "^3.11.12" 11 | aiodns = "^3.2.0" 12 | pycryptodome = "^3.21.0" 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | pdoc = "^15.0.3" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /scripts/create_pre_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Bump the version in the package.json file 4 | poetry version patch 5 | 6 | # Get the new version 7 | version=$(poetry version -s) 8 | 9 | # Create a new branch 10 | git checkout -b release/$version 11 | 12 | # Commit the change 13 | git add . 14 | git commit -m "chore(pyproject.toml): Bumps version to $version" 15 | 16 | # Push the branch 17 | git push origin release/$version -------------------------------------------------------------------------------- /src/wyzeapy/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import logging 7 | from inspect import iscoroutinefunction 8 | from typing import List, Optional, Set, Callable 9 | 10 | from .const import PHONE_SYSTEM_TYPE, APP_VERSION, SC, APP_VER, SV, PHONE_ID, APP_NAME, OLIVE_APP_ID, APP_INFO 11 | from .crypto import olive_create_signature 12 | from .exceptions import TwoFactorAuthenticationEnabled 13 | from .payload_factory import olive_create_user_info_payload 14 | from .services.base_service import BaseService 15 | from .services.bulb_service import BulbService 16 | from .services.camera_service import CameraService 17 | from .services.hms_service import HMSService 18 | from .services.lock_service import LockService 19 | from .services.sensor_service import SensorService 20 | from .services.switch_service import SwitchService, SwitchUsageService 21 | from .services.thermostat_service import ThermostatService 22 | from .services.wall_switch_service import WallSwitchService 23 | from .wyze_auth_lib import WyzeAuthLib, Token 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | class Wyzeapy: 29 | """A Python module to assist developers in interacting with the Wyze service API. 30 | 31 | This class provides methods for authentication, device management, and accessing 32 | various Wyze device services including: 33 | 34 | * **Bulbs** - Control brightness, color, and power state 35 | * **Switches** - Toggle power and monitor usage 36 | * **Cameras** - Access video streams and control settings 37 | * **Thermostats** - Manage temperature settings and modes 38 | * **Locks** - Control and monitor door locks 39 | * **Sensors** - Monitor motion, contact, and environmental sensors 40 | * **HMS** - Manage home monitoring system 41 | 42 | Most interactions with Wyze devices should go through this class. 43 | """ 44 | # _client: Client 45 | _auth_lib: WyzeAuthLib 46 | 47 | def __init__(self): 48 | self._bulb_service = None 49 | self._switch_service = None 50 | self._camera_service = None 51 | self._thermostat_service = None 52 | self._hms_service = None 53 | self._lock_service = None 54 | self._sensor_service = None 55 | self._wall_switch_service = None 56 | self._switch_usage_service = None 57 | self._email = None 58 | self._password = None 59 | self._key_id = None 60 | self._api_key = None 61 | self._service: Optional[BaseService] = None 62 | self._token_callbacks: List[Callable] = [] 63 | 64 | @classmethod 65 | async def create(cls): 66 | """ 67 | Creates and initializes the Wyzeapy class asynchronously. 68 | 69 | This factory method provides a way to instantiate the class using async/await syntax, 70 | though it's currently a simple implementation that may be expanded in the future. 71 | 72 | **Returns:** 73 | `Wyzeapy`: A new instance of the Wyzeapy class ready for authentication. 74 | """ 75 | self = cls() 76 | return self 77 | 78 | async def login( 79 | self, email, password, key_id, api_key, token: Optional[Token] = None 80 | ): 81 | """ 82 | Authenticates with the Wyze API and retrieves the user's access token. 83 | 84 | This method handles the authentication process, including token management 85 | and service initialization. If two-factor authentication is enabled on the account, 86 | it will raise an exception requiring the use of `login_with_2fa()` instead. 87 | 88 | **Args:** 89 | * `email` (str): User's email address for Wyze account 90 | * `password` (str): User's password for Wyze account 91 | * `key_id` (str): Key ID for third-party API access 92 | * `api_key` (str): API Key for third-party API access 93 | * `token` (Optional[Token], optional): Existing token from a previous session. Defaults to None. 94 | 95 | **Raises:** 96 | * `TwoFactorAuthenticationEnabled`: When the account has 2FA enabled and requires verification 97 | """ 98 | 99 | self._email = email 100 | self._password = password 101 | self._key_id = key_id 102 | self._api_key = api_key 103 | 104 | try: 105 | self._auth_lib = await WyzeAuthLib.create( 106 | email, password, key_id, api_key, token, self.execute_token_callbacks 107 | ) 108 | if token: 109 | # User token supplied, refresh on startup 110 | await self._auth_lib.refresh() 111 | else: 112 | await self._auth_lib.get_token_with_username_password( 113 | email, password, key_id, api_key 114 | ) 115 | self._service = BaseService(self._auth_lib) 116 | except TwoFactorAuthenticationEnabled as error: 117 | raise error 118 | 119 | async def login_with_2fa(self, verification_code) -> Token: 120 | """ 121 | Completes the login process for accounts with two-factor authentication enabled. 122 | 123 | This method should be called after receiving a `TwoFactorAuthenticationEnabled` 124 | exception from the `login()` method. It completes the authentication process 125 | using the verification code sent to the user. 126 | 127 | **Args:** 128 | * `verification_code` (str): The 2FA verification code received by the user 129 | 130 | **Returns:** 131 | * `Token`: The authenticated user token object 132 | """ 133 | 134 | _LOGGER.debug(f"Verification Code: {verification_code}") 135 | 136 | await self._auth_lib.get_token_with_2fa(verification_code) 137 | self._service = BaseService(self._auth_lib) 138 | return self._auth_lib.token 139 | 140 | async def execute_token_callbacks(self, token: Token): 141 | """ 142 | Sends the token to all registered callback functions. 143 | 144 | This method is called internally whenever the token is refreshed or updated, 145 | allowing external components to stay in sync with token changes. 146 | 147 | **Args:** 148 | * `token` (Token): The current user token object 149 | """ 150 | for callback in self._token_callbacks: 151 | if iscoroutinefunction(callback): 152 | await callback(token) 153 | else: 154 | callback(token) 155 | 156 | def register_for_token_callback(self, callback_function): 157 | """ 158 | Registers a callback function to be called whenever the user's token is modified. 159 | 160 | This allows external components to be notified of token changes for persistence 161 | or other token-dependent operations. 162 | 163 | **Args:** 164 | * `callback_function`: A function that accepts a Token object as its parameter 165 | 166 | **Example:** 167 | ```python 168 | def token_updated(token): 169 | print(f"Token refreshed: {token.access_token[:10]}...") 170 | 171 | wyze = Wyzeapy() 172 | wyze.register_for_token_callback(token_updated) 173 | ``` 174 | """ 175 | self._token_callbacks.append(callback_function) 176 | 177 | def unregister_for_token_callback(self, callback_function): 178 | """ 179 | Removes a previously registered token callback function. 180 | 181 | This stops the specified callback from receiving token updates. 182 | 183 | **Args:** 184 | * `callback_function`: The callback function to remove from the notification list 185 | """ 186 | self._token_callbacks.remove(callback_function) 187 | 188 | @property 189 | async def unique_device_ids(self) -> Set[str]: 190 | """ 191 | Retrieves a set of all unique device IDs known to the Wyze server. 192 | 193 | This property fetches all devices associated with the account and 194 | extracts their MAC addresses as unique identifiers. 195 | 196 | **Returns:** 197 | * `Set[str]`: A set containing all unique device IDs (MAC addresses) 198 | 199 | **Example:** 200 | ```python 201 | device_ids = await wyze.unique_device_ids 202 | print(f"Found {len(device_ids)} devices") 203 | ``` 204 | """ 205 | 206 | devices = await self._service.get_object_list() 207 | device_ids = set() 208 | for device in devices: 209 | device_ids.add(device.mac) 210 | 211 | return device_ids 212 | 213 | @property 214 | async def notifications_are_on(self) -> bool: 215 | """ 216 | Checks if push notifications are enabled for the account. 217 | 218 | This property queries the user profile to determine the current 219 | notification settings status. 220 | 221 | **Returns:** 222 | * `bool`: True if notifications are enabled, False otherwise 223 | """ 224 | 225 | response_json = await self._service.get_user_profile() 226 | return response_json['data']['notification'] 227 | 228 | async def enable_notifications(self): 229 | """Enables push notifications for the Wyze account. 230 | 231 | This method updates the user's profile to turn on push notifications 232 | for all supported devices and events. 233 | 234 | **Example:** 235 | ```python 236 | # Turn on notifications 237 | await wyze.enable_notifications() 238 | ``` 239 | """ 240 | 241 | await self._service.set_push_info(True) 242 | 243 | async def disable_notifications(self): 244 | """Disables push notifications for the Wyze account. 245 | 246 | This method updates the user's profile to turn off push notifications 247 | for all devices and events. 248 | 249 | **Example:** 250 | ```python 251 | # Turn off notifications 252 | await wyze.disable_notifications() 253 | ``` 254 | """ 255 | 256 | await self._service.set_push_info(False) 257 | 258 | @classmethod 259 | async def valid_login( 260 | cls, email: str, password: str, key_id: str, api_key: str 261 | ) -> bool: 262 | """ 263 | Validates if the provided credentials can successfully authenticate with the Wyze API. 264 | 265 | This method attempts to log in with the provided credentials and returns whether 266 | the authentication was successful. It's useful for validating credentials without 267 | needing to handle the full login process. 268 | 269 | **Args:** 270 | * `email` (str): The user's email address 271 | * `password` (str): The user's password 272 | * `key_id` (str): Key ID for third-party API access 273 | * `api_key` (str): API Key for third-party API access 274 | 275 | **Returns:** 276 | * `bool`: True if the credentials are valid and authentication succeeded 277 | 278 | **Example:** 279 | ```python 280 | is_valid = await Wyzeapy.valid_login("user@example.com", "password123", "key_id", "api_key") 281 | if is_valid: 282 | print("Credentials are valid") 283 | else: 284 | print("Invalid credentials") 285 | ``` 286 | """ 287 | 288 | self = cls() 289 | await self.login(email, password, key_id, api_key) 290 | 291 | return not self._auth_lib.should_refresh 292 | 293 | @property 294 | async def bulb_service(self) -> BulbService: 295 | """Provides access to the Wyze Bulb service. 296 | 297 | This property lazily initializes and returns a BulbService instance 298 | for controlling and monitoring Wyze bulbs. 299 | 300 | **Returns:** 301 | * `BulbService`: An instance of the bulb service for interacting with Wyze bulbs 302 | 303 | **Example:** 304 | ```python 305 | # Get all bulbs 306 | bulb_service = await wyze.bulb_service 307 | bulbs = await bulb_service.get_bulbs() 308 | ``` 309 | """ 310 | 311 | if self._bulb_service is None: 312 | self._bulb_service = BulbService(self._auth_lib) 313 | return self._bulb_service 314 | 315 | @property 316 | async def switch_service(self) -> SwitchService: 317 | """Provides access to the Wyze Switch service. 318 | 319 | This property lazily initializes and returns a SwitchService instance 320 | for controlling and monitoring Wyze plugs and switches. 321 | 322 | **Returns:** 323 | * `SwitchService`: An instance of the switch service for interacting with Wyze switches 324 | 325 | **Example:** 326 | ```python 327 | # Get all switches 328 | switch_service = await wyze.switch_service 329 | switches = await switch_service.get_switches() 330 | ``` 331 | """ 332 | 333 | if self._switch_service is None: 334 | self._switch_service = SwitchService(self._auth_lib) 335 | return self._switch_service 336 | 337 | @property 338 | async def camera_service(self) -> CameraService: 339 | """Provides access to the Wyze Camera service. 340 | 341 | This property lazily initializes and returns a CameraService instance 342 | for controlling and monitoring Wyze cameras. 343 | 344 | **Returns:** 345 | * `CameraService`: An instance of the camera service for interacting with Wyze cameras 346 | 347 | **Example:** 348 | ```python 349 | # Get all cameras 350 | camera_service = await wyze.camera_service 351 | cameras = await camera_service.get_cameras() 352 | ``` 353 | """ 354 | 355 | if self._camera_service is None: 356 | self._camera_service = CameraService(self._auth_lib) 357 | return self._camera_service 358 | 359 | @property 360 | async def thermostat_service(self) -> ThermostatService: 361 | """Provides access to the Wyze Thermostat service. 362 | 363 | This property lazily initializes and returns a ThermostatService instance 364 | for controlling and monitoring Wyze thermostats. 365 | 366 | **Returns:** 367 | * `ThermostatService`: An instance of the thermostat service for interacting with Wyze thermostats 368 | 369 | **Example:** 370 | ```python 371 | # Get all thermostats 372 | thermostat_service = await wyze.thermostat_service 373 | thermostats = await thermostat_service.get_thermostats() 374 | ``` 375 | """ 376 | 377 | if self._thermostat_service is None: 378 | self._thermostat_service = ThermostatService(self._auth_lib) 379 | return self._thermostat_service 380 | 381 | @property 382 | async def hms_service(self) -> HMSService: 383 | """Provides access to the Wyze Home Monitoring Service (HMS). 384 | 385 | This property lazily initializes and returns an HMSService instance 386 | for controlling and monitoring the Wyze home security system. 387 | 388 | **Returns:** 389 | * `HMSService`: An instance of the HMS service for interacting with Wyze home monitoring 390 | 391 | **Example:** 392 | ```python 393 | # Get HMS status 394 | hms_service = await wyze.hms_service 395 | status = await hms_service.get_hms_status() 396 | ``` 397 | """ 398 | 399 | if self._hms_service is None: 400 | self._hms_service = await HMSService.create(self._auth_lib) 401 | return self._hms_service 402 | 403 | @property 404 | async def lock_service(self) -> LockService: 405 | """Provides access to the Wyze Lock service. 406 | 407 | This property lazily initializes and returns a LockService instance 408 | for controlling and monitoring Wyze locks. 409 | 410 | **Returns:** 411 | * `LockService`: An instance of the lock service for interacting with Wyze locks 412 | 413 | **Example:** 414 | ```python 415 | # Get all locks 416 | lock_service = await wyze.lock_service 417 | locks = await lock_service.get_locks() 418 | ``` 419 | """ 420 | 421 | if self._lock_service is None: 422 | self._lock_service = LockService(self._auth_lib) 423 | return self._lock_service 424 | 425 | @property 426 | async def sensor_service(self) -> SensorService: 427 | """Provides access to the Wyze Sensor service. 428 | 429 | This property lazily initializes and returns a SensorService instance 430 | for monitoring Wyze sensors such as contact sensors, motion sensors, etc. 431 | 432 | **Returns:** 433 | * `SensorService`: An instance of the sensor service for interacting with Wyze sensors 434 | 435 | **Example:** 436 | ```python 437 | # Get all sensors 438 | sensor_service = await wyze.sensor_service 439 | sensors = await sensor_service.get_sensors() 440 | ``` 441 | """ 442 | 443 | if self._sensor_service is None: 444 | self._sensor_service = SensorService(self._auth_lib) 445 | return self._sensor_service 446 | 447 | @property 448 | async def wall_switch_service(self) -> WallSwitchService: 449 | """Provides access to the Wyze Wall Switch service. 450 | 451 | This property lazily initializes and returns a WallSwitchService instance 452 | for controlling and monitoring Wyze wall switches. 453 | 454 | **Returns:** 455 | * `WallSwitchService`: An instance of the wall switch service for interacting with Wyze wall switches 456 | 457 | **Example:** 458 | ```python 459 | # Get all wall switches 460 | wall_switch_service = await wyze.wall_switch_service 461 | switches = await wall_switch_service.get_wall_switches() 462 | ``` 463 | """ 464 | 465 | if self._wall_switch_service is None: 466 | self._wall_switch_service = WallSwitchService(self._auth_lib) 467 | return self._wall_switch_service 468 | 469 | @property 470 | async def switch_usage_service(self) -> SwitchUsageService: 471 | """Provides access to the Wyze Switch Usage service. 472 | 473 | This property lazily initializes and returns a SwitchUsageService instance 474 | for retrieving usage statistics from Wyze switches and plugs. 475 | 476 | **Returns:** 477 | * `SwitchUsageService`: An instance of the switch usage service for accessing Wyze switch usage data 478 | 479 | **Example:** 480 | ```python 481 | # Get usage data for a switch 482 | usage_service = await wyze.switch_usage_service 483 | usage = await usage_service.get_usage_records(switch_mac) 484 | ``` 485 | """ 486 | if self._switch_usage_service is None: 487 | self._switch_usage_service = SwitchUsageService(self._auth_lib) 488 | return self._switch_usage_service 489 | -------------------------------------------------------------------------------- /src/wyzeapy/const.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import uuid 7 | 8 | # Here is where all the *magic* lives 9 | PHONE_SYSTEM_TYPE = "1" 10 | API_KEY = "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ" 11 | APP_VERSION = "2.18.43" 12 | APP_VER = "com.hualai.WyzeCam___2.18.43" 13 | APP_NAME = "com.hualai.WyzeCam" 14 | PHONE_ID = str(uuid.uuid4()) 15 | APP_INFO = 'wyze_android_2.19.14' # Required for the thermostat 16 | SC = "9f275790cab94a72bd206c8876429f3c" 17 | SV = "9d74946e652647e9b6c9d59326aef104" 18 | CLIENT_VER = "2" 19 | SOURCE = "ios/WZCameraSDK" 20 | APP_PLATFORM = "ios" 21 | 22 | # Crypto secrets 23 | OLIVE_SIGNING_SECRET = 'wyze_app_secret_key_132' # Required for the thermostat 24 | OLIVE_APP_ID = '9319141212m2ik' # Required for the thermostat 25 | FORD_APP_KEY = "275965684684dbdaf29a0ed9" # Required for the locks 26 | FORD_APP_SECRET = "4deekof1ba311c5c33a9cb8e12787e8c" # Required for the locks 27 | -------------------------------------------------------------------------------- /src/wyzeapy/crypto.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import hashlib 7 | import hmac 8 | import urllib.parse 9 | from typing import Dict, Union, Any 10 | 11 | from .const import FORD_APP_SECRET, OLIVE_SIGNING_SECRET 12 | 13 | 14 | def olive_create_signature(payload: Union[Dict[Any, Any], str], access_token: str) -> str: 15 | if isinstance(payload, dict): 16 | body = "" 17 | for item in sorted(payload): 18 | body += item + "=" + str(payload[item]) + "&" 19 | 20 | body = body[:-1] 21 | 22 | else: 23 | body = payload 24 | 25 | access_key = "{}{}".format(access_token, OLIVE_SIGNING_SECRET) 26 | 27 | secret = hashlib.md5(access_key.encode()).hexdigest() 28 | return hmac.new(secret.encode(), body.encode(), hashlib.md5).hexdigest() 29 | 30 | 31 | def ford_create_signature(url_path: str, request_method: str, payload: Dict[Any, Any]) -> str: 32 | string_buf = request_method + url_path 33 | for entry in sorted(payload.keys()): 34 | string_buf += entry + "=" + payload[entry] + "&" 35 | 36 | string_buf = string_buf[:-1] 37 | string_buf += FORD_APP_SECRET 38 | urlencoded = urllib.parse.quote_plus(string_buf) 39 | return hashlib.md5(urlencoded.encode()).hexdigest() 40 | -------------------------------------------------------------------------------- /src/wyzeapy/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | from typing import Dict, Any 7 | 8 | 9 | class ActionNotSupported(Exception): 10 | def __init__(self, device_type: str): 11 | message = "The action specified is not supported by device type: {}".format(device_type) 12 | 13 | super().__init__(message) 14 | 15 | 16 | class ParameterError(Exception): 17 | pass 18 | 19 | 20 | class AccessTokenError(Exception): 21 | pass 22 | 23 | 24 | class LoginError(Exception): 25 | pass 26 | 27 | 28 | class UnknownApiError(Exception): 29 | pass 30 | 31 | 32 | class TwoFactorAuthenticationEnabled(Exception): 33 | pass 34 | -------------------------------------------------------------------------------- /src/wyzeapy/payload_factory.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import time 7 | from typing import Any, Dict 8 | 9 | from .const import FORD_APP_KEY 10 | from .crypto import ford_create_signature 11 | 12 | 13 | def ford_create_payload(access_token: str, payload: Dict[str, Any], 14 | url_path: str, request_method: str) -> Dict[str, Any]: 15 | payload["access_token"] = access_token 16 | payload["key"] = FORD_APP_KEY 17 | payload["timestamp"] = str(int(time.time() * 1000)) 18 | payload["sign"] = ford_create_signature(url_path, request_method, payload) 19 | return payload 20 | 21 | 22 | def olive_create_get_payload(device_mac: str, keys: str) -> Dict[str, Any]: 23 | nonce = int(time.time() * 1000) 24 | 25 | return { 26 | 'keys': keys, 27 | 'did': device_mac, 28 | 'nonce': nonce 29 | } 30 | 31 | 32 | def olive_create_post_payload(device_mac: str, device_model: str, prop_key: str, value: Any) -> Dict[str, Any]: 33 | nonce = int(time.time() * 1000) 34 | 35 | return { 36 | "did": device_mac, 37 | "model": device_model, 38 | "props": { 39 | prop_key: value 40 | }, 41 | "is_sub_device": 0, 42 | "nonce": str(nonce) 43 | } 44 | 45 | 46 | def olive_create_hms_payload() -> Dict[str, str]: 47 | nonce = int(time.time() * 1000) 48 | 49 | return { 50 | "group_id": "hms", 51 | "nonce": str(nonce) 52 | } 53 | 54 | 55 | def olive_create_user_info_payload() -> Dict[str, str]: 56 | nonce = int(time.time() * 1000) 57 | 58 | return { 59 | "nonce": str(nonce) 60 | } 61 | 62 | 63 | def olive_create_hms_get_payload(hms_id: str) -> Dict[str, str]: 64 | nonce = int(time.time() * 1000) 65 | return { 66 | "hms_id": hms_id, 67 | "nonce": str(nonce) 68 | } 69 | 70 | 71 | def olive_create_hms_patch_payload(hms_id: str) -> Dict[str, Any]: 72 | return { 73 | "hms_id": hms_id 74 | } 75 | 76 | 77 | def devicemgmt_create_capabilities_payload(type: str, value: str): 78 | match type: 79 | case "floodlight": 80 | return { 81 | "iid": 4, 82 | "name": "floodlight", 83 | "properties": [ 84 | { 85 | "prop": "on", 86 | "value": value 87 | } 88 | ] 89 | } 90 | case "spotlight": 91 | return { 92 | "iid": 5, 93 | "name": "spotlight", 94 | "properties": [ 95 | { 96 | "prop": "on", 97 | "value": value 98 | } 99 | ] 100 | } 101 | case "power": 102 | return { 103 | "functions": [ 104 | { 105 | "in": { 106 | "wakeup-live-view": "1" 107 | }, 108 | "name": value 109 | } 110 | ], 111 | "iid": 1, 112 | "name": "iot-device" 113 | } 114 | case "siren": 115 | return { 116 | "functions": [ 117 | { 118 | "in": {}, 119 | "name": value 120 | } 121 | ], 122 | "name": "siren" 123 | } 124 | case _: 125 | raise NotImplementedError(f"No action of type ({type}) has been implemented.") 126 | 127 | 128 | def devicemgmt_get_iot_props_list(model: str): 129 | match model: 130 | case "LD_CFP": # Floodlight Pro 131 | return [ 132 | { 133 | "iid": 2, 134 | "name": "camera", 135 | "properties": [ 136 | "motion-detect", 137 | "resolution", 138 | "bit-rate", 139 | "live-stream-mode", 140 | "recording-mode", 141 | "frame-rate", 142 | "night-shot", 143 | "night-shot-state", 144 | "rotate-angle", 145 | "time-watermark", 146 | "logo-watermark", 147 | "recording-trigger-source", 148 | "recording-content-type", 149 | "motion-push", 150 | "speaker", 151 | "microphone", 152 | "unusual-sound-push", 153 | "flip", 154 | "motion-detect-recording", 155 | "cool-down-interval", 156 | "infrared-mode", 157 | "sound-collection-on", 158 | "live-stream-protocol", 159 | "ai-push", 160 | "voice-template", 161 | "motion-category" 162 | ] 163 | }, 164 | { 165 | "iid": 3, 166 | "name": "device-info", 167 | "properties": [ 168 | "device-id", 169 | "device-model", 170 | "firmware-ver", 171 | "mac", 172 | "timezone", 173 | "lat", 174 | "ip", 175 | "lon", 176 | "hardware-ver", 177 | "public-ip" 178 | ] 179 | }, 180 | { 181 | "iid": 1, 182 | "name": "iot-device", 183 | "properties": [ 184 | "iot-state", 185 | "iot-power", 186 | "push-switch" 187 | ] 188 | }, 189 | { 190 | "iid": 9, 191 | "name": "camera-ai", 192 | "properties": [ 193 | "smart-detection-type", 194 | "on" 195 | ] 196 | }, 197 | { 198 | "iid": 4, 199 | "name": "floodlight", 200 | "properties": [ 201 | "on", 202 | "enabled", 203 | "mode", 204 | "trigger-source", 205 | "brightness", 206 | "light-on-duration", 207 | "voice-template", 208 | "motion-warning-switch", 209 | "motion-activate-light-switch", 210 | "motion-activate-light-schedule", 211 | "motion-activate-brightness", 212 | "ambient-light-switch", 213 | "ambient-light-schedule", 214 | "ambient-light-brightness", 215 | "motion-tag", 216 | "light-model", 217 | "flash-with-siren" 218 | ] 219 | }, 220 | { 221 | "iid": 11, 222 | "name": "indicator-light", 223 | "properties": [ 224 | "on", 225 | "mode", 226 | "brightness", 227 | "color", 228 | "color-temperature" 229 | ] 230 | }, 231 | { 232 | "iid": 8, 233 | "name": "memory-card-management", 234 | "properties": [ 235 | "storage-used-space", 236 | "storage-total-space", 237 | "storage-status", 238 | "sd-card-playback-enabled" 239 | ] 240 | }, 241 | { 242 | "iid": 6, 243 | "name": "motion-detection", 244 | "properties": [ 245 | "sensitivity-motion", 246 | "on", 247 | "motion-zone", 248 | "motion-zone-selected-block", 249 | "motion-zone-block-size", 250 | "motion-tag", 251 | "edge-detection-type", 252 | "motion-warning-switch", 253 | "motion-warning-tone", 254 | "motion-warning-interval", 255 | "motion-warning-schedule", 256 | "motion-warning-sound", 257 | "motion-warning-trigger-setting" 258 | ] 259 | }, 260 | { 261 | "iid": 7, 262 | "name": "siren", 263 | "properties": [ 264 | "state" 265 | ] 266 | }, 267 | { 268 | "iid": 5, 269 | "name": "wifi", 270 | "properties": [ 271 | "on", 272 | "signal-strength", 273 | "wifi-ssid", 274 | "wifi-encrypted-password" 275 | ] 276 | } 277 | ] 278 | case "AN_RSCW": # Battery Cam pro 279 | return [ 280 | { 281 | "iid": 2, 282 | "name": "camera", 283 | "properties": [ 284 | "motion-detect", 285 | "resolution", 286 | "bit-rate", 287 | "live-stream-mode", 288 | "recording-mode", 289 | "frame-rate", 290 | "night-shot", 291 | "night-shot-state", 292 | "time-watermark", 293 | "logo-watermark", 294 | "cool-down-interval", 295 | "recording-content-type", 296 | "video-length-limit", 297 | "motion-push", 298 | "speaker", 299 | "unusual-sound-push", 300 | "microphone", 301 | "infrared-mode", 302 | "motion-detect-recording", 303 | "live-stream-protocol", 304 | "recording-resolution", 305 | "recording-start-time", 306 | "recording-schedule-duration", 307 | "voice-template", 308 | "rotate-angle", 309 | "sound-collection-on", 310 | "ai-push" 311 | ] 312 | }, 313 | { 314 | "iid": 3, 315 | "name": "device-info", 316 | "properties": [ 317 | "device-id", 318 | "device-model", 319 | "firmware-ver", 320 | "mac", 321 | "timezone", 322 | "lat", 323 | "ip", 324 | "lon", 325 | "company-code", 326 | "device-setting-channel", 327 | "network-connection-mode", 328 | "hardware-ver", 329 | "public-ip" 330 | ] 331 | }, 332 | { 333 | "iid": 1, 334 | "name": "iot-device", 335 | "properties": [ 336 | "iot-state", 337 | "iot-power", 338 | "push-switch", 339 | "mqtt-check" 340 | ] 341 | }, 342 | { 343 | "iid": 7, 344 | "name": "battery", 345 | "properties": [ 346 | "battery-level", 347 | "low-battery-push", 348 | "power-source", 349 | "charging-status", 350 | "power-saving" 351 | ] 352 | }, 353 | { 354 | "iid": 12, 355 | "name": "camera-ai", 356 | "properties": [ 357 | "smart-detection-type", 358 | "on" 359 | ] 360 | }, 361 | { 362 | "iid": 8, 363 | "name": "indicator-light", 364 | "properties": [ 365 | "on", 366 | "mode" 367 | ] 368 | }, 369 | { 370 | "iid": 6, 371 | "name": "memory-card-management", 372 | "properties": [ 373 | "storage-used-space", 374 | "storage-total-space", 375 | "storage-status", 376 | "sd-card-playback-enabled" 377 | ] 378 | }, 379 | { 380 | "iid": 11, 381 | "name": "motion-detection", 382 | "properties": [ 383 | "sensitivity-motion", 384 | "on", 385 | "area-length", 386 | "motion-zone", 387 | "motion-zone-block-size", 388 | "motion-zone-selected-block", 389 | "edge-detection-type", 390 | "motion-tag" 391 | ] 392 | }, 393 | { 394 | "iid": 4, 395 | "name": "siren", 396 | "properties": [ 397 | "state", 398 | "siren-on-ts" 399 | ] 400 | }, 401 | { 402 | "iid": 14, 403 | "name": "solar-panel", 404 | "properties": [ 405 | "enabled", 406 | "on" 407 | ] 408 | }, 409 | { 410 | "iid": 5, 411 | "name": "spotlight", 412 | "properties": [ 413 | "on", 414 | "enabled", 415 | "brightness", 416 | "motion-activate-light-switch", 417 | "sunset-to-sunrise", 418 | "motion-activate-light-schedule", 419 | "trigger-source" 420 | ] 421 | }, 422 | { 423 | "iid": 9, 424 | "name": "wifi", 425 | "properties": [ 426 | "on", 427 | "signal-strength", 428 | "wifi-ssid", 429 | "wifi-encrypted-password" 430 | ] 431 | } 432 | ] 433 | case "GW_GC1": # OG 434 | return [ 435 | { 436 | "iid": 2, 437 | "name": "camera", 438 | "properties": [ 439 | "motion-detect", 440 | "resolution", 441 | "bit-rate", 442 | "live-stream-mode", 443 | "recording-mode", 444 | "frame-rate", 445 | "night-shot", 446 | "night-shot-state", 447 | "time-watermark", 448 | "logo-watermark", 449 | "cool-down-interval", 450 | "recording-content-type", 451 | "video-length-limit", 452 | "motion-push", 453 | "speaker", 454 | "unusual-sound-push", 455 | "microphone", 456 | "infrared-mode", 457 | "motion-detect-recording", 458 | "live-stream-protocol", 459 | "recording-resolution", 460 | "recording-start-time", 461 | "recording-schedule-duration", 462 | "voice-template", 463 | "rotate-angle", 464 | "sound-collection-on", 465 | "ai-push" 466 | ] 467 | }, 468 | { 469 | "iid": 3, 470 | "name": "device-info", 471 | "properties": [ 472 | "device-id", 473 | "device-model", 474 | "firmware-ver", 475 | "mac", 476 | "timezone", 477 | "lat", 478 | "ip", 479 | "lon", 480 | "company-code", 481 | "device-setting-channel", 482 | "network-connection-mode", 483 | "hardware-ver", 484 | "public-ip" 485 | ] 486 | }, 487 | { 488 | "iid": 1, 489 | "name": "iot-device", 490 | "properties": [ 491 | "iot-state", 492 | "iot-power", 493 | "push-switch", 494 | "mqtt-check" 495 | ] 496 | }, 497 | { 498 | "iid": 12, 499 | "name": "camera-ai", 500 | "properties": [ 501 | "smart-detection-type", 502 | "on" 503 | ] 504 | }, 505 | { 506 | "iid": 8, 507 | "name": "indicator-light", 508 | "properties": [ 509 | "on", 510 | "mode" 511 | ] 512 | }, 513 | { 514 | "iid": 6, 515 | "name": "memory-card-management", 516 | "properties": [ 517 | "storage-used-space", 518 | "storage-total-space", 519 | "storage-status", 520 | "sd-card-playback-enabled" 521 | ] 522 | }, 523 | { 524 | "iid": 11, 525 | "name": "motion-detection", 526 | "properties": [ 527 | "sensitivity-motion", 528 | "on", 529 | "area-length", 530 | "motion-zone", 531 | "motion-zone-block-size", 532 | "motion-zone-selected-block", 533 | "edge-detection-type", 534 | "motion-tag" 535 | ] 536 | }, 537 | { 538 | "iid": 4, 539 | "name": "siren", 540 | "properties": [ 541 | "state", 542 | "siren-on-ts" 543 | ] 544 | }, 545 | { 546 | "iid": 5, 547 | "name": "spotlight", 548 | "properties": [ 549 | "on", 550 | "enabled", 551 | "brightness", 552 | "motion-activate-light-switch", 553 | "sunset-to-sunrise", 554 | "motion-activate-light-schedule", 555 | "trigger-source" 556 | ] 557 | }, 558 | { 559 | "iid": 9, 560 | "name": "wifi", 561 | "properties": [ 562 | "on", 563 | "signal-strength", 564 | "wifi-ssid", 565 | "wifi-encrypted-password" 566 | ] 567 | } 568 | ] 569 | case _: 570 | raise NotImplementedError(f"No iot props for model ({model}) have been defined.") -------------------------------------------------------------------------------- /src/wyzeapy/services/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides the services for interacting with Wyze devices. 3 | """ -------------------------------------------------------------------------------- /src/wyzeapy/services/base_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import asyncio 7 | import json 8 | import logging 9 | import time 10 | from typing import List, Tuple, Any, Dict, Optional 11 | 12 | import aiohttp 13 | 14 | from .update_manager import DeviceUpdater, UpdateManager 15 | from ..const import PHONE_SYSTEM_TYPE, APP_VERSION, APP_VER, PHONE_ID, APP_NAME, OLIVE_APP_ID, APP_INFO, SC, SV, APP_PLATFORM, SOURCE 16 | from ..crypto import olive_create_signature 17 | from ..payload_factory import olive_create_hms_patch_payload, olive_create_hms_payload, \ 18 | olive_create_hms_get_payload, ford_create_payload, olive_create_get_payload, olive_create_post_payload, \ 19 | olive_create_user_info_payload, devicemgmt_create_capabilities_payload, devicemgmt_get_iot_props_list 20 | from ..types import PropertyIDs, Device, DeviceMgmtToggleType 21 | from ..utils import check_for_errors_standard, check_for_errors_hms, check_for_errors_lock, \ 22 | check_for_errors_iot, wyze_encrypt, check_for_errors_devicemgmt 23 | from ..wyze_auth_lib import WyzeAuthLib 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | class BaseService: 29 | """Base service class for interacting with Wyze devices. 30 | 31 | This class provides the base functionality for interacting with Wyze devices. 32 | """ 33 | _devices: Optional[List[Device]] = None 34 | _last_updated_time: time = 0 # preload a value of 0 so that comparison will succeed on the first run 35 | _min_update_time = 1200 # lets let the device_params update every 20 minutes for now. This could probably reduced signicficantly. 36 | _update_lock: asyncio.Lock = asyncio.Lock() # fmt: skip 37 | _update_manager: UpdateManager = UpdateManager() 38 | _update_loop = None 39 | _updater: DeviceUpdater = None 40 | _updater_dict = {} 41 | 42 | def __init__(self, auth_lib: WyzeAuthLib): 43 | """Initialize the base service. 44 | 45 | :param auth_lib: The authentication library to use. 46 | """ 47 | self._auth_lib = auth_lib 48 | 49 | @staticmethod 50 | async def start_update_manager(): 51 | """Start the update manager.""" 52 | if BaseService._update_loop is None: 53 | BaseService._update_loop = asyncio.get_event_loop() 54 | BaseService._update_loop.create_task(BaseService._update_manager.update_next()) 55 | 56 | def register_updater(self, device: Device, interval): 57 | """Register a device to be updated at a specific interval. 58 | 59 | :param device: The device to register. 60 | :param interval: The interval in seconds. 61 | """ 62 | self._updater = DeviceUpdater(self, device, interval) 63 | BaseService._update_manager.add_updater(self._updater) 64 | self._updater_dict[self._updater.device] = self._updater 65 | 66 | def unregister_updater(self, device: Device): 67 | """Unregister a device from being updated. 68 | 69 | :param device: The device to unregister. 70 | """ 71 | if self._updater: 72 | BaseService._update_manager.del_updater(self._updater_dict[device]) 73 | del self._updater_dict[device] 74 | 75 | async def set_push_info(self, on: bool): 76 | """Set push info for the user. 77 | 78 | :param on: Whether to enable or disable push notifications. 79 | """ 80 | await self._auth_lib.refresh_if_should() 81 | 82 | url = "https://api.wyzecam.com/app/user/set_push_info" 83 | payload = { 84 | "phone_system_type": PHONE_SYSTEM_TYPE, 85 | "app_version": APP_VERSION, 86 | "app_ver": APP_VER, 87 | "push_switch": "1" if on else "2", 88 | "sc": SC, 89 | "ts": int(time.time()), 90 | "sv": SV, 91 | "access_token": self._auth_lib.token.access_token, 92 | "phone_id": PHONE_ID, 93 | "app_name": APP_NAME 94 | } 95 | 96 | response_json = await self._auth_lib.post(url, json=payload) 97 | 98 | check_for_errors_standard(self, response_json) 99 | 100 | async def get_user_profile(self) -> Dict[Any, Any]: 101 | """Get user profile. 102 | 103 | :return: User profile. 104 | """ 105 | await self._auth_lib.refresh_if_should() 106 | 107 | payload = olive_create_user_info_payload() 108 | signature = olive_create_signature(payload, self._auth_lib.token.access_token) 109 | headers = { 110 | 'Accept-Encoding': 'gzip', 111 | 'User-Agent': 'myapp', 112 | 'appid': OLIVE_APP_ID, 113 | 'appinfo': APP_INFO, 114 | 'phoneid': PHONE_ID, 115 | 'access_token': self._auth_lib.token.access_token, 116 | 'signature2': signature 117 | } 118 | 119 | url = 'https://wyze-platform-service.wyzecam.com/app/v2/platform/get_user_profile' 120 | 121 | response_json = await self._auth_lib.get(url, headers=headers, params=payload) 122 | 123 | return response_json 124 | 125 | async def get_object_list(self) -> List[Device]: 126 | """Wraps the api.wyzecam.com/app/v2/home_page/get_object_list endpoint 127 | 128 | :return: List of devices 129 | """ 130 | await self._auth_lib.refresh_if_should() 131 | 132 | payload = { 133 | "phone_system_type": PHONE_SYSTEM_TYPE, 134 | "app_version": APP_VERSION, 135 | "app_ver": APP_VER, 136 | "sc": "9f275790cab94a72bd206c8876429f3c", 137 | "ts": int(time.time()), 138 | "sv": "9d74946e652647e9b6c9d59326aef104", 139 | "access_token": self._auth_lib.token.access_token, 140 | "phone_id": PHONE_ID, 141 | "app_name": APP_NAME 142 | } 143 | 144 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/home_page/get_object_list", 145 | json=payload) 146 | 147 | check_for_errors_standard(self, response_json) 148 | # Cache the devices so that update calls can pull more recent device_params 149 | BaseService._devices = [Device(device) for device in response_json['data']['device_list']] 150 | 151 | return BaseService._devices 152 | 153 | async def get_updated_params(self, device_mac: str = None) -> Dict[str, Optional[Any]]: 154 | """Get updated params for a device. 155 | 156 | :param device_mac: The device mac to get updated params for. 157 | :return: Updated params for the device. 158 | """ 159 | if time.time() - BaseService._last_updated_time >= BaseService._min_update_time: 160 | await self.get_object_list() 161 | BaseService._last_updated_time = time.time() 162 | ret_params = {} 163 | for dev in BaseService._devices: 164 | if dev.mac == device_mac: 165 | ret_params = dev.device_params 166 | return ret_params 167 | 168 | async def _get_property_list(self, device: Device) -> List[Tuple[PropertyIDs, Any]]: 169 | """Wraps the api.wyzecam.com/app/v2/device/get_property_list endpoint 170 | 171 | :param device: Device to get properties for 172 | :return: List of PropertyIDs and values 173 | """ 174 | 175 | await self._auth_lib.refresh_if_should() 176 | 177 | payload = { 178 | "phone_system_type": PHONE_SYSTEM_TYPE, 179 | "app_version": APP_VERSION, 180 | "app_ver": APP_VER, 181 | "sc": "9f275790cab94a72bd206c8876429f3c", 182 | "ts": int(time.time()), 183 | "sv": "9d74946e652647e9b6c9d59326aef104", 184 | "access_token": self._auth_lib.token.access_token, 185 | "phone_id": PHONE_ID, 186 | "app_name": APP_NAME, 187 | "device_model": device.product_model, 188 | "device_mac": device.mac, 189 | "target_pid_list": [] 190 | } 191 | 192 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/device/get_property_list", 193 | json=payload) 194 | 195 | check_for_errors_standard(self, response_json) 196 | properties = response_json['data']['property_list'] 197 | property_list = [] 198 | for prop in properties: 199 | try: 200 | property_id = PropertyIDs(prop['pid']) 201 | property_list.append(( 202 | property_id, 203 | prop['value'] 204 | )) 205 | except ValueError: 206 | pass 207 | 208 | return property_list 209 | 210 | async def _set_property_list(self, device: Device, plist: List[Dict[str, str]]): 211 | """Wraps the api.wyzecam.com/app/v2/device/set_property_list endpoint 212 | 213 | :param device: The device for which to set the property(ies) 214 | :param plist: A list of properties [{"pid": pid, "pvalue": pvalue},...] 215 | """ 216 | 217 | await self._auth_lib.refresh_if_should() 218 | 219 | payload = { 220 | "phone_system_type": PHONE_SYSTEM_TYPE, 221 | "app_version": APP_VERSION, 222 | "app_ver": APP_VER, 223 | "sc": "9f275790cab94a72bd206c8876429f3c", 224 | "ts": int(time.time()), 225 | "sv": "9d74946e652647e9b6c9d59326aef104", 226 | "access_token": self._auth_lib.token.access_token, 227 | "phone_id": PHONE_ID, 228 | "app_name": APP_NAME, 229 | "property_list": plist, 230 | "device_model": device.product_model, 231 | "device_mac": device.mac 232 | } 233 | 234 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/device/set_property_list", 235 | json=payload) 236 | 237 | check_for_errors_standard(self, response_json) 238 | 239 | async def _run_action_list(self, device: Device, plist: List[Dict[Any, Any]]): 240 | """Wraps the api.wyzecam.com/app/v2/auto/run_action_list endpoint 241 | 242 | :param device: The device for which to run the action list 243 | :param plist: A list of properties [{"pid": pid, "pvalue": pvalue},...] 244 | """ 245 | await self._auth_lib.refresh_if_should() 246 | 247 | payload = { 248 | "phone_system_type": PHONE_SYSTEM_TYPE, 249 | "app_version": APP_VERSION, 250 | "app_ver": APP_VER, 251 | "sc": "9f275790cab94a72bd206c8876429f3c", 252 | "ts": int(time.time()), 253 | "sv": "9d74946e652647e9b6c9d59326aef104", 254 | "access_token": self._auth_lib.token.access_token, 255 | "phone_id": PHONE_ID, 256 | "app_name": APP_NAME, 257 | "action_list": [ 258 | { 259 | "instance_id": device.mac, 260 | "action_params": { 261 | "list": [ 262 | { 263 | "mac": device.mac, 264 | "plist": plist 265 | } 266 | ] 267 | }, 268 | "provider_key": device.product_model, 269 | "action_key": "set_mesh_property" 270 | } 271 | ] 272 | } 273 | 274 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/auto/run_action_list", 275 | json=payload) 276 | 277 | check_for_errors_standard(self, response_json) 278 | 279 | async def _get_event_list(self, count: int) -> Dict[Any, Any]: 280 | """Wraps the api.wyzecam.com/app/v2/device/get_event_list endpoint 281 | 282 | :param count: Number of events to gather 283 | :return: Response from the server after being validated 284 | """ 285 | 286 | await self._auth_lib.refresh_if_should() 287 | 288 | payload = { 289 | "phone_id": PHONE_ID, 290 | "begin_time": int((time.time() - (60 * 60)) * 1000), 291 | "event_type": "", 292 | "app_name": APP_NAME, 293 | "count": count, 294 | "app_version": APP_VERSION, 295 | "order_by": 2, 296 | "event_value_list": [ 297 | "1", 298 | "13", 299 | "10", 300 | "12" 301 | ], 302 | "sc": "9f275790cab94a72bd206c8876429f3c", 303 | "device_mac_list": [], 304 | "event_tag_list": [], 305 | "sv": "782ced6909a44d92a1f70d582bbe88be", 306 | "end_time": int(time.time() * 1000), 307 | "phone_system_type": PHONE_SYSTEM_TYPE, 308 | "app_ver": APP_VER, 309 | "ts": 1623612037763, 310 | "device_mac": "", 311 | "access_token": self._auth_lib.token.access_token 312 | } 313 | 314 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/device/get_event_list", 315 | json=payload) 316 | 317 | check_for_errors_standard(self, response_json) 318 | return response_json 319 | 320 | async def _run_action(self, device: Device, action: str): 321 | """Wraps the api.wyzecam.com/app/v2/auto/run_action endpoint 322 | 323 | :param device: The device for which to run the action 324 | :param action: The action to run 325 | """ 326 | 327 | await self._auth_lib.refresh_if_should() 328 | 329 | payload = { 330 | "phone_system_type": PHONE_SYSTEM_TYPE, 331 | "app_version": APP_VERSION, 332 | "app_ver": APP_VER, 333 | "sc": "9f275790cab94a72bd206c8876429f3c", 334 | "ts": int(time.time()), 335 | "sv": "9d74946e652647e9b6c9d59326aef104", 336 | "access_token": self._auth_lib.token.access_token, 337 | "phone_id": PHONE_ID, 338 | "app_name": APP_NAME, 339 | "provider_key": device.product_model, 340 | "instance_id": device.mac, 341 | "action_key": action, 342 | "action_params": {}, 343 | "custom_string": "", 344 | } 345 | 346 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/auto/run_action", 347 | json=payload) 348 | 349 | check_for_errors_standard(self, response_json) 350 | 351 | async def _run_action_devicemgmt(self, device: Device, type: str, value: str): 352 | """Wraps the devicemgmt-service-beta.wyze.com/device-management/api/action/run_action endpoint 353 | 354 | :param device: The device for which to run the action 355 | :param type: The type of action to run 356 | :param value: The value of the action to run 357 | """ 358 | 359 | await self._auth_lib.refresh_if_should() 360 | 361 | capabilities = devicemgmt_create_capabilities_payload(type, value) 362 | 363 | payload = { 364 | "capabilities": [ 365 | capabilities 366 | ], 367 | "nonce": int(time.time() * 1000), 368 | "targetInfo": { 369 | "id": device.mac, 370 | "productModel": device.product_model, 371 | "type": "DEVICE" 372 | }, 373 | "transactionId": "0a5b20591fedd4du1b93f90743ba0csd" # OG cam needs this (doesn't matter what the value is) 374 | } 375 | 376 | headers = { 377 | "authorization": self._auth_lib.token.access_token, 378 | } 379 | 380 | response_json = await self._auth_lib.post("https://devicemgmt-service-beta.wyze.com/device-management/api/action/run_action", 381 | json=payload, headers=headers) 382 | 383 | check_for_errors_iot(self, response_json) 384 | 385 | async def _set_toggle(self, device: Device, toggleType: DeviceMgmtToggleType, state: str): 386 | """Wraps the ai-subscription-service-beta.wyzecam.com/v4/subscription-service/toggle-management endpoint 387 | 388 | :param device: The device for which to get the state 389 | :param toggleType: Enum for the toggle type 390 | :param state: String state to set for the toggle 391 | """ 392 | 393 | await self._auth_lib.refresh_if_should() 394 | 395 | payload = { 396 | "data": [ 397 | { 398 | "device_firmware": "1234567890", 399 | "device_id": device.mac, 400 | "device_model": device.product_model, 401 | "page_id": [ 402 | toggleType.pageId 403 | ], 404 | "toggle_update": [ 405 | { 406 | "toggle_id": toggleType.toggleId, 407 | "toggle_status": state 408 | } 409 | ] 410 | } 411 | ], 412 | "nonce": str(int(time.time() * 1000)) 413 | } 414 | 415 | 416 | signature = olive_create_signature(payload, self._auth_lib.token.access_token) 417 | headers = { 418 | "access_token": self._auth_lib.token.access_token, 419 | "timestamp": str(int(time.time() * 1000)), 420 | "appid": OLIVE_APP_ID, 421 | "source": SOURCE, 422 | "signature2": signature, 423 | "appplatform": APP_PLATFORM, 424 | "appversion": APP_VERSION, 425 | "requestid": "35374158s4s313b9a2be7c057f2da5d1" 426 | } 427 | 428 | response_json = await self._auth_lib.put("https://ai-subscription-service-beta.wyzecam.com/v4/subscription-service/toggle-management", 429 | json=payload, headers=headers) 430 | 431 | check_for_errors_devicemgmt(self, response_json) 432 | 433 | async def _get_iot_prop_devicemgmt(self, device: Device) -> Dict[str, Any]: 434 | """Wraps the devicemgmt-service-beta.wyze.com/device-management/api/device-property/get_iot_prop endpoint 435 | 436 | :param device: The device for which to get the state 437 | :return: Response from the server after being validated 438 | """ 439 | 440 | await self._auth_lib.refresh_if_should() 441 | 442 | payload = { 443 | "capabilities": devicemgmt_get_iot_props_list(device.product_model), 444 | "nonce": int(time.time() * 1000), 445 | "targetInfo": { 446 | "id": device.mac, 447 | "productModel": device.product_model, 448 | "type": "DEVICE" 449 | } 450 | } 451 | 452 | headers = { 453 | "authorization": self._auth_lib.token.access_token, 454 | } 455 | 456 | response_json = await self._auth_lib.post("https://devicemgmt-service-beta.wyze.com/device-management/api/device-property/get_iot_prop", 457 | json=payload, headers=headers) 458 | 459 | check_for_errors_iot(self, response_json) 460 | 461 | return response_json 462 | 463 | async def _set_property(self, device: Device, pid: str, pvalue: str): 464 | """Wraps the api.wyzecam.com/app/v2/device/set_property endpoint 465 | 466 | :param device: The device for which to set the property 467 | :param pid: The property id 468 | :param pvalue: The property value 469 | """ 470 | await self._auth_lib.refresh_if_should() 471 | 472 | payload = { 473 | "phone_system_type": PHONE_SYSTEM_TYPE, 474 | "app_version": APP_VERSION, 475 | "app_ver": APP_VER, 476 | "sc": "9f275790cab94a72bd206c8876429f3c", 477 | "ts": int(time.time()), 478 | "sv": "9d74946e652647e9b6c9d59326aef104", 479 | "access_token": self._auth_lib.token.access_token, 480 | "phone_id": PHONE_ID, 481 | "app_name": APP_NAME, 482 | "pvalue": pvalue, 483 | "pid": pid, 484 | "device_model": device.product_model, 485 | "device_mac": device.mac 486 | } 487 | 488 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/device/set_property", 489 | json=payload) 490 | 491 | check_for_errors_standard(self, response_json) 492 | 493 | async def _monitoring_profile_active(self, hms_id: str, home: int, away: int): 494 | """Wraps the hms.api.wyze.com/api/v1/monitoring/v1/profile/active endpoint 495 | 496 | :param hms_id: The hms id 497 | :param home: 1 for home 0 for not 498 | :param away: 1 for away 0 for not 499 | """ 500 | await self._auth_lib.refresh_if_should() 501 | 502 | url = "https://hms.api.wyze.com/api/v1/monitoring/v1/profile/active" 503 | query = olive_create_hms_patch_payload(hms_id) 504 | signature = olive_create_signature(query, self._auth_lib.token.access_token) 505 | headers = { 506 | 'Accept-Encoding': 'gzip', 507 | 'User-Agent': 'myapp', 508 | 'appid': OLIVE_APP_ID, 509 | 'appinfo': APP_INFO, 510 | 'phoneid': PHONE_ID, 511 | 'access_token': self._auth_lib.token.access_token, 512 | 'signature2': signature, 513 | 'Authorization': self._auth_lib.token.access_token 514 | } 515 | payload = [ 516 | { 517 | "state": "home", 518 | "active": home 519 | }, 520 | { 521 | "state": "away", 522 | "active": away 523 | } 524 | ] 525 | response_json = await self._auth_lib.patch(url, headers=headers, params=query, json=payload) 526 | check_for_errors_hms(self, response_json) 527 | 528 | async def _get_plan_binding_list_by_user(self) -> Dict[Any, Any]: 529 | """Wraps the wyze-membership-service.wyzecam.com/platform/v2/membership/get_plan_binding_list_by_user endpoint 530 | 531 | :return: The response to gathering the plan for the current user 532 | """ 533 | 534 | if self._auth_lib.should_refresh: 535 | await self._auth_lib.refresh() 536 | 537 | url = "https://wyze-membership-service.wyzecam.com/platform/v2/membership/get_plan_binding_list_by_user" 538 | payload = olive_create_hms_payload() 539 | signature = olive_create_signature(payload, self._auth_lib.token.access_token) 540 | headers = { 541 | 'Accept-Encoding': 'gzip', 542 | 'User-Agent': 'myapp', 543 | 'appid': OLIVE_APP_ID, 544 | 'appinfo': APP_INFO, 545 | 'phoneid': PHONE_ID, 546 | 'access_token': self._auth_lib.token.access_token, 547 | 'signature2': signature 548 | } 549 | 550 | response_json = await self._auth_lib.get(url, headers=headers, params=payload) 551 | check_for_errors_hms(self, response_json) 552 | return response_json 553 | 554 | async def _disable_reme_alarm(self, hms_id: str) -> None: 555 | """ 556 | Wraps the hms.api.wyze.com/api/v1/reme-alarm endpoint 557 | 558 | :param hms_id: The hms_id for the account 559 | """ 560 | await self._auth_lib.refresh_if_should() 561 | 562 | url = "https://hms.api.wyze.com/api/v1/reme-alarm" 563 | payload = { 564 | "hms_id": hms_id, 565 | "remediation_id": "emergency" 566 | } 567 | headers = { 568 | "Authorization": self._auth_lib.token.access_token 569 | } 570 | 571 | response_json = await self._auth_lib.delete(url, headers=headers, json=payload) 572 | 573 | check_for_errors_hms(self, response_json) 574 | 575 | async def _monitoring_profile_state_status(self, hms_id: str) -> Dict[Any, Any]: 576 | """ 577 | Wraps the hms.api.wyze.com/api/v1/monitoring/v1/profile/state-status endpoint 578 | 579 | :param hms_id: The hms_id 580 | :return: The response that includes the status 581 | """ 582 | if self._auth_lib.should_refresh: 583 | await self._auth_lib.refresh() 584 | 585 | url = "https://hms.api.wyze.com/api/v1/monitoring/v1/profile/state-status" 586 | query = olive_create_hms_get_payload(hms_id) 587 | signature = olive_create_signature(query, self._auth_lib.token.access_token) 588 | headers = { 589 | 'User-Agent': 'myapp', 590 | 'appid': OLIVE_APP_ID, 591 | 'appinfo': APP_INFO, 592 | 'phoneid': PHONE_ID, 593 | 'access_token': self._auth_lib.token.access_token, 594 | 'signature2': signature, 595 | 'Authorization': self._auth_lib.token.access_token, 596 | 'Content-Type': "application/json" 597 | } 598 | 599 | response_json = await self._auth_lib.get(url, headers=headers, params=query) 600 | 601 | check_for_errors_hms(self, response_json) 602 | return response_json 603 | 604 | async def _lock_control(self, device: Device, action: str) -> None: 605 | await self._auth_lib.refresh_if_should() 606 | 607 | url_path = "/openapi/lock/v1/control" 608 | 609 | device_uuid = device.mac.split(".")[-1] 610 | 611 | payload = { 612 | "uuid": device_uuid, 613 | "action": action # "remoteLock" or "remoteUnlock" 614 | } 615 | payload = ford_create_payload(self._auth_lib.token.access_token, payload, url_path, "post") 616 | 617 | url = "https://yd-saas-toc.wyzecam.com/openapi/lock/v1/control" 618 | 619 | response_json = await self._auth_lib.post(url, json=payload) 620 | 621 | check_for_errors_lock(self, response_json) 622 | 623 | async def _get_lock_info(self, device: Device) -> Dict[str, Optional[Any]]: 624 | await self._auth_lib.refresh_if_should() 625 | 626 | url_path = "/openapi/lock/v1/info" 627 | 628 | device_uuid = device.mac.split(".")[-1] 629 | 630 | payload = { 631 | "uuid": device_uuid, 632 | "with_keypad": "1" 633 | } 634 | 635 | payload = ford_create_payload(self._auth_lib.token.access_token, payload, url_path, "get") 636 | 637 | url = "https://yd-saas-toc.wyzecam.com/openapi/lock/v1/info" 638 | 639 | response_json = await self._auth_lib.get(url, params=payload) 640 | 641 | check_for_errors_lock(self, response_json) 642 | 643 | return response_json 644 | 645 | async def _get_lock_ble_token(self, device: Device) -> Dict[str, Optional[Any]]: 646 | await self._auth_lib.refresh_if_should() 647 | 648 | url_path = "/openapi/lock/v1/ble/token" 649 | 650 | payload = { 651 | "uuid": device.mac 652 | } 653 | 654 | payload = ford_create_payload(self._auth_lib.token.access_token, payload, url_path, "get") 655 | 656 | url = f"https://yd-saas-toc.wyzecam.com{url_path}" 657 | 658 | response_json = await self._auth_lib.get(url, params=payload) 659 | 660 | check_for_errors_lock(self, response_json) 661 | 662 | return response_json 663 | 664 | async def _get_device_info(self, device: Device) -> Dict[Any, Any]: 665 | await self._auth_lib.refresh_if_should() 666 | 667 | payload = { 668 | "phone_system_type": PHONE_SYSTEM_TYPE, 669 | "app_version": APP_VERSION, 670 | "app_ver": APP_VER, 671 | "device_mac": device.mac, 672 | "sc": "9f275790cab94a72bd206c8876429f3c", 673 | "ts": int(time.time()), 674 | "device_model": device.product_model, 675 | "sv": "c86fa16fc99d4d6580f82ef3b942e586", 676 | "access_token": self._auth_lib.token.access_token, 677 | "phone_id": PHONE_ID, 678 | "app_name": APP_NAME 679 | } 680 | 681 | response_json = await self._auth_lib.post("https://api.wyzecam.com/app/v2/device/get_device_Info", 682 | json=payload) 683 | 684 | check_for_errors_standard(self, response_json) 685 | 686 | return response_json 687 | 688 | async def _get_iot_prop(self, url: str, device: Device, keys: str) -> Dict[Any, Any]: 689 | await self._auth_lib.refresh_if_should() 690 | 691 | payload = olive_create_get_payload(device.mac, keys) 692 | signature = olive_create_signature(payload, self._auth_lib.token.access_token) 693 | headers = { 694 | 'Accept-Encoding': 'gzip', 695 | 'User-Agent': 'myapp', 696 | 'appid': OLIVE_APP_ID, 697 | 'appinfo': APP_INFO, 698 | 'phoneid': PHONE_ID, 699 | 'access_token': self._auth_lib.token.access_token, 700 | 'signature2': signature 701 | } 702 | 703 | response_json = await self._auth_lib.get(url, headers=headers, params=payload) 704 | 705 | check_for_errors_iot(self, response_json) 706 | 707 | return response_json 708 | 709 | async def _set_iot_prop(self, url: str, device: Device, prop_key: str, value: Any) -> None: 710 | await self._auth_lib.refresh_if_should() 711 | 712 | payload = olive_create_post_payload(device.mac, device.product_model, prop_key, value) 713 | signature = olive_create_signature(json.dumps(payload, separators=(',', ':')), 714 | self._auth_lib.token.access_token) 715 | headers = { 716 | 'Accept-Encoding': 'gzip', 717 | 'Content-Type': 'application/json', 718 | 'User-Agent': 'myapp', 719 | 'appid': OLIVE_APP_ID, 720 | 'appinfo': APP_INFO, 721 | 'phoneid': PHONE_ID, 722 | 'access_token': self._auth_lib.token.access_token, 723 | 'signature2': signature 724 | } 725 | 726 | payload_str = json.dumps(payload, separators=(',', ':')) 727 | 728 | response_json = await self._auth_lib.post(url, headers=headers, data=payload_str) 729 | 730 | check_for_errors_iot(self, response_json) 731 | 732 | async def _local_bulb_command(self, bulb, plist): 733 | # await self._auth_lib.refresh_if_should() 734 | 735 | characteristics = { 736 | "mac": bulb.mac.upper(), 737 | "index": "1", 738 | "ts": str(int(time.time_ns() // 1000000)), 739 | "plist": plist 740 | } 741 | 742 | characteristics_str = json.dumps(characteristics, separators=(',', ':')) 743 | characteristics_enc = wyze_encrypt(bulb.enr, characteristics_str) 744 | 745 | payload = { 746 | "request": "set_status", 747 | "isSendQueue": 0, 748 | "characteristics": characteristics_enc 749 | } 750 | 751 | # JSON likes to add a second \ so we have to remove it for the bulb to be happy 752 | payload_str = json.dumps(payload, separators=(',', ':')).replace('\\\\', '\\') 753 | 754 | url = "http://%s:88/device_request" % bulb.ip 755 | 756 | try: 757 | async with aiohttp.ClientSession() as session: 758 | async with session.post(url, data=payload_str) as response: 759 | print(await response.text()) 760 | except aiohttp.ClientConnectionError: 761 | _LOGGER.warning("Failed to connect to bulb %s, reverting to cloud." % bulb.mac) 762 | await self._run_action_list(bulb, plist) 763 | bulb.cloud_fallback = True 764 | 765 | async def _get_plug_history( 766 | self, device: Device, start_time, end_time 767 | ) -> Dict[Any, Any]: 768 | """Wraps the https://api.wyzecam.com/app/v2/plug/usage_record_list endpoint""" 769 | 770 | await self._auth_lib.refresh_if_should() 771 | 772 | payload = { 773 | "phone_id": PHONE_ID, 774 | "date_begin": start_time, 775 | "date_end": end_time, 776 | "app_name": APP_NAME, 777 | "app_version": APP_VERSION, 778 | "sc": SC, 779 | "device_mac": device.mac, 780 | "sv": SV, 781 | "phone_system_type": PHONE_SYSTEM_TYPE, 782 | "app_ver": APP_VER, 783 | "ts": int(time.time()), 784 | "access_token": self._auth_lib.token.access_token, 785 | } 786 | 787 | response_json = await self._auth_lib.post( 788 | "https://api.wyzecam.com/app/v2/plug/usage_record_list", json=payload 789 | ) 790 | 791 | check_for_errors_standard(self, response_json) 792 | 793 | return response_json["data"]["usage_record_list"] 794 | -------------------------------------------------------------------------------- /src/wyzeapy/services/bulb_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import logging 7 | import re 8 | from typing import Any, Dict, Optional, List 9 | 10 | from .base_service import BaseService 11 | from ..types import Device, PropertyIDs, DeviceTypes 12 | from ..utils import create_pid_pair 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class Bulb(Device): 18 | """Bulb class for interacting with Wyze bulbs.""" 19 | _brightness: int = 0 20 | _color_temp: int = 1800 21 | _color: Optional[str] 22 | 23 | def __init__(self, dictionary: Dict[Any, Any]): 24 | """Initialize the Bulb class. 25 | 26 | :param dictionary: Dictionary containing the device parameters. 27 | """ 28 | self.enr: str = "" 29 | """Encryption string""" 30 | self.on: bool = False 31 | """Variable that stores the on/off state of the bulb""" 32 | self.cloud_fallback: bool = False 33 | """Variable that stores the cloud fallback state of the bulb""" 34 | super().__init__(dictionary) 35 | 36 | self.ip = self.device_params["ip"] 37 | """IP address of the bulb""" 38 | 39 | if ( 40 | self.type is DeviceTypes.MESH_LIGHT 41 | or self.type is DeviceTypes.LIGHTSTRIP 42 | ): 43 | self._color = "000000" 44 | 45 | @property 46 | def brightness(self) -> int: 47 | """Property that stores the brightness of the bulb 48 | :return: Brightness of the bulb 49 | """ 50 | return self._brightness 51 | 52 | @brightness.setter 53 | def brightness(self, value: int) -> None: 54 | """Setter for the brightness property 55 | :param value: Brightness of the bulb 56 | """ 57 | assert value <= 100 58 | assert value >= 0 59 | self._brightness = value 60 | 61 | @property 62 | def color_temp(self) -> int: 63 | """Property that stores the color temperature of the bulb 64 | :return: Color temperature of the bulb 65 | """ 66 | return self._color_temp 67 | 68 | @color_temp.setter 69 | def color_temp(self, value: int) -> None: 70 | """Setter for the color temperature property 71 | :param value: Color temperature of the bulb 72 | """ 73 | self._color_temp = value 74 | 75 | @property 76 | def color(self) -> Optional[str]: 77 | """Property that stores the color of the bulb 78 | :return: Color of the bulb 79 | """ 80 | return self._color 81 | 82 | @color.setter 83 | def color(self, value) -> None: 84 | """Setter for the color property 85 | :param value: Color of the bulb 86 | """ 87 | assert re.match(r"^([A-Fa-f\d]{6}|[A-Fa-f\d]{3})$", value) is not None 88 | self._color = value 89 | 90 | 91 | class BulbService(BaseService): 92 | """Bulb service for interacting with Wyze bulbs.""" 93 | async def update(self, bulb: Bulb) -> Bulb: 94 | """Update the bulb object with the latest device parameters. 95 | 96 | :param bulb: Bulb object to update 97 | :return: Updated bulb object 98 | """ 99 | # Get updated device_params 100 | async with BaseService._update_lock: 101 | bulb.device_params = await self.get_updated_params(bulb.mac) 102 | 103 | device_info = await self._get_property_list(bulb) 104 | for property_id, value in device_info: 105 | if property_id == PropertyIDs.BRIGHTNESS: 106 | bulb.brightness = int(float(value)) 107 | elif property_id == PropertyIDs.COLOR_TEMP: 108 | try: 109 | bulb.color_temp = int(value) 110 | except ValueError: 111 | bulb.color_temp = 2700 112 | elif property_id == PropertyIDs.ON: 113 | bulb.on = value == "1" 114 | elif property_id == PropertyIDs.AVAILABLE: 115 | bulb.available = value == "1" 116 | elif ( 117 | property_id == PropertyIDs.COLOR 118 | and bulb.type in [DeviceTypes.LIGHTSTRIP, DeviceTypes.MESH_LIGHT] 119 | ): 120 | bulb.color = value 121 | elif property_id == PropertyIDs.COLOR_MODE: 122 | bulb.color_mode = value 123 | elif property_id == PropertyIDs.SUN_MATCH: 124 | bulb.sun_match = value == "1" 125 | elif property_id == PropertyIDs.LIGHTSTRIP_EFFECTS: 126 | bulb.effects = value 127 | elif property_id == PropertyIDs.LIGHTSTRIP_MUSIC_MODE: 128 | bulb.music_mode = value == "1" 129 | 130 | return bulb 131 | 132 | async def get_bulbs(self) -> List[Bulb]: 133 | """Get a list of all bulbs. 134 | 135 | :return: List of Bulb objects 136 | """ 137 | if self._devices is None: 138 | self._devices = await self.get_object_list() 139 | 140 | bulbs = [ 141 | device 142 | for device in self._devices 143 | if device.type in [DeviceTypes.LIGHT, 144 | DeviceTypes.MESH_LIGHT, 145 | DeviceTypes.LIGHTSTRIP] 146 | ] 147 | 148 | return [Bulb(bulb.raw_dict) for bulb in bulbs] 149 | 150 | async def turn_on(self, bulb: Bulb, local_control, options=None): 151 | plist = [ 152 | create_pid_pair(PropertyIDs.ON, "1") 153 | ] 154 | 155 | if options is not None: 156 | plist.extend(options) 157 | 158 | if bulb.type is DeviceTypes.LIGHT: 159 | await self._set_property_list(bulb, plist) 160 | 161 | elif ( 162 | bulb.type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP] 163 | ): 164 | # Local Control 165 | if local_control and not bulb.cloud_fallback: 166 | await self._local_bulb_command(bulb, plist) 167 | 168 | # Cloud Control 169 | elif bulb.type is DeviceTypes.MESH_LIGHT: # Sun match for mesh bulbs needs to be set on a different endpoint for some reason 170 | for item in plist: 171 | if item["pid"] == PropertyIDs.SUN_MATCH.value: 172 | await self._set_property_list(bulb, [item]) 173 | plist.remove(item) 174 | await self._run_action_list(bulb, plist) 175 | else: 176 | await self._run_action_list(bulb, plist) # Lightstrips 177 | 178 | async def turn_off(self, bulb: Bulb, local_control): 179 | plist = [ 180 | create_pid_pair(PropertyIDs.ON, "0") 181 | ] 182 | 183 | if bulb.type in [ 184 | DeviceTypes.LIGHT 185 | ]: 186 | await self._set_property_list(bulb, plist) 187 | elif ( 188 | bulb.type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP] 189 | ): 190 | if local_control and not bulb.cloud_fallback: 191 | await self._local_bulb_command(bulb, plist) 192 | else: 193 | await self._run_action_list(bulb, plist) 194 | 195 | async def set_color_temp(self, bulb: Bulb, color_temp: int): 196 | plist = [ 197 | create_pid_pair(PropertyIDs.COLOR_TEMP, str(color_temp)) 198 | ] 199 | 200 | if bulb.type in [ 201 | DeviceTypes.LIGHT 202 | ]: 203 | await self._set_property_list(bulb, plist) 204 | elif bulb.type in [ 205 | DeviceTypes.MESH_LIGHT 206 | ]: 207 | await self._local_bulb_command(bulb, plist) 208 | 209 | async def set_color(self, bulb: Bulb, color: str, local_control): 210 | plist = [ 211 | create_pid_pair(PropertyIDs.COLOR, str(color)) 212 | ] 213 | if bulb.type in [ 214 | DeviceTypes.MESH_LIGHT 215 | ]: 216 | if local_control and not bulb.cloud_fallback: 217 | await self._local_bulb_command(bulb, plist) 218 | else: 219 | await self._run_action_list(bulb, plist) 220 | 221 | async def set_brightness(self, bulb: Device, brightness: int): 222 | plist = [ 223 | create_pid_pair(PropertyIDs.BRIGHTNESS, str(brightness)) 224 | ] 225 | 226 | if bulb.type in [ 227 | DeviceTypes.LIGHT 228 | ]: 229 | await self._set_property_list(bulb, plist) 230 | if bulb.type in [ 231 | DeviceTypes.MESH_LIGHT 232 | ]: 233 | await self._local_bulb_command(bulb, plist) 234 | 235 | async def music_mode_on(self, bulb: Device): 236 | plist = [ 237 | create_pid_pair(PropertyIDs.LIGHTSTRIP_MUSIC_MODE, "1") 238 | ] 239 | 240 | await self._run_action_list(bulb, plist) 241 | 242 | async def music_mode_off(self, bulb: Device): 243 | plist = [ 244 | create_pid_pair(PropertyIDs.LIGHTSTRIP_MUSIC_MODE, "0") 245 | ] 246 | 247 | await self._run_action_list(bulb, plist) 248 | 249 | 250 | -------------------------------------------------------------------------------- /src/wyzeapy/services/camera_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import asyncio 7 | import logging 8 | import time 9 | from threading import Thread 10 | from typing import Any, List, Optional, Dict, Callable, Tuple 11 | 12 | from aiohttp import ClientOSError, ContentTypeError 13 | 14 | from ..exceptions import UnknownApiError 15 | from .base_service import BaseService 16 | from .update_manager import DeviceUpdater 17 | from ..types import Device, DeviceTypes, Event, PropertyIDs, DeviceMgmtToggleProps 18 | from ..utils import return_event_for_device, create_pid_pair 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | # NOTE: Make sure to also define props in devicemgmt_create_capabilities_payload() 23 | DEVICEMGMT_API_MODELS = ["LD_CFP", "AN_RSCW", "GW_GC1"] # Floodlight pro, battery cam pro, and OG use a diffrent api (devicemgmt) 24 | 25 | 26 | class Camera(Device): 27 | def __init__(self, dictionary: Dict[Any, Any]): 28 | super().__init__(dictionary) 29 | 30 | self.last_event: Optional[Event] = None 31 | self.last_event_ts: int = int(time.time() * 1000) 32 | self.on: bool = True 33 | self.siren: bool = False 34 | self.floodlight: bool = False 35 | self.garage: bool = False 36 | 37 | 38 | class CameraService(BaseService): 39 | _updater_thread: Optional[Thread] = None 40 | _subscribers: List[Tuple[Camera, Callable[[Camera], None]]] = [] 41 | 42 | async def update(self, camera: Camera): 43 | # Get updated device_params 44 | async with BaseService._update_lock: 45 | camera.device_params = await self.get_updated_params(camera.mac) 46 | 47 | # Get camera events 48 | response = await self._get_event_list(10) 49 | raw_events = response['data']['event_list'] 50 | latest_events = [Event(raw_event) for raw_event in raw_events] 51 | 52 | if (event := return_event_for_device(camera, latest_events)) is not None: 53 | camera.last_event = event 54 | camera.last_event_ts = event.event_ts 55 | 56 | # Update camera state 57 | if (camera.product_model in DEVICEMGMT_API_MODELS): # New api 58 | state_response: Dict[str, Any] = await self._get_iot_prop_devicemgmt(camera) 59 | for propCategory in state_response['data']['capabilities']: 60 | if propCategory['name'] == "camera": 61 | camera.motion = propCategory['properties']['motion-detect-recording'] 62 | if propCategory['name'] == "floodlight" or propCategory['name'] == "spotlight": 63 | camera.floodlight = propCategory['properties']['on'] 64 | if propCategory['name'] == "siren": 65 | camera.siren = propCategory['properties']['state'] 66 | if propCategory['name'] == "iot-device": 67 | camera.notify = propCategory['properties']['push-switch'] 68 | camera.on = propCategory['properties']['iot-power'] 69 | camera.available = propCategory['properties']['iot-state'] 70 | 71 | else: # All other cam types (old api?) 72 | state_response: List[Tuple[PropertyIDs, Any]] = await self._get_property_list(camera) 73 | for property, value in state_response: 74 | if property is PropertyIDs.AVAILABLE: 75 | camera.available = value == "1" 76 | if property is PropertyIDs.ON: 77 | camera.on = value == "1" 78 | if property is PropertyIDs.CAMERA_SIREN: 79 | camera.siren = value == "1" 80 | if property is PropertyIDs.ACCESSORY: 81 | camera.floodlight = value == "1" 82 | if camera.device_params["dongle_product_model"] == "HL_CGDC": 83 | camera.garage = value == "1" # 1 = open, 2 = closed by automation or smart platform (Alexa, Google Home, Rules), 0 = closed by app 84 | if property is PropertyIDs.NOTIFICATION: 85 | camera.notify = value == "1" 86 | if property is PropertyIDs.MOTION_DETECTION: 87 | camera.motion = value == "1" 88 | 89 | return camera 90 | 91 | async def register_for_updates(self, camera: Camera, callback: Callable[[Camera], None]): 92 | loop = asyncio.get_event_loop() 93 | if not self._updater_thread: 94 | self._updater_thread = Thread(target=self.update_worker, args=[loop, ], daemon=True) 95 | self._updater_thread.start() 96 | 97 | self._subscribers.append((camera, callback)) 98 | 99 | async def deregister_for_updates(self, camera: Camera): 100 | self._subscribers = [(cam, callback) for cam, callback in self._subscribers if cam.mac != camera.mac] 101 | 102 | def update_worker(self, loop): 103 | while True: 104 | if len(self._subscribers) < 1: 105 | time.sleep(0.1) 106 | else: 107 | for camera, callback in self._subscribers: 108 | try: 109 | callback(asyncio.run_coroutine_threadsafe(self.update(camera), loop).result()) 110 | except UnknownApiError as e: 111 | _LOGGER.warning(f"The update method detected an UnknownApiError: {e}") 112 | except ClientOSError as e: 113 | _LOGGER.error(f"A network error was detected: {e}") 114 | except ContentTypeError as e: 115 | _LOGGER.error(f"Server returned unexpected ContentType: {e}") 116 | 117 | async def get_cameras(self) -> List[Camera]: 118 | if self._devices is None: 119 | self._devices = await self.get_object_list() 120 | 121 | cameras = [device for device in self._devices if device.type is DeviceTypes.CAMERA] 122 | 123 | return [Camera(camera.raw_dict) for camera in cameras] 124 | 125 | async def turn_on(self, camera: Camera): 126 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._run_action_devicemgmt(camera, "power", "wakeup") # Some camera models use a diffrent api 127 | else: await self._run_action(camera, "power_on") 128 | 129 | async def turn_off(self, camera: Camera): 130 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._run_action_devicemgmt(camera, "power", "sleep") # Some camera models use a diffrent api 131 | else: await self._run_action(camera, "power_off") 132 | 133 | async def siren_on(self, camera: Camera): 134 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._run_action_devicemgmt(camera, "siren", "siren-on") # Some camera models use a diffrent api 135 | else: await self._run_action(camera, "siren_on") 136 | 137 | async def siren_off(self, camera: Camera): 138 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._run_action_devicemgmt(camera, "siren", "siren-off") # Some camera models use a diffrent api 139 | else: await self._run_action(camera, "siren_off") 140 | 141 | # Also controls lamp socket and BCP spotlight 142 | async def floodlight_on(self, camera: Camera): 143 | if (camera.product_model == "AN_RSCW"): await self._run_action_devicemgmt(camera, "spotlight", "1") # Battery cam pro integrated spotlight is controllable 144 | elif (camera.product_model in DEVICEMGMT_API_MODELS): await self._run_action_devicemgmt(camera, "floodlight", "1") # Some camera models use a diffrent api 145 | else: await self._set_property(camera, PropertyIDs.ACCESSORY.value, "1") 146 | 147 | # Also controls lamp socket and BCP spotlight 148 | async def floodlight_off(self, camera: Camera): 149 | if (camera.product_model == "AN_RSCW"): await self._run_action_devicemgmt(camera, "spotlight", "0") # Battery cam pro integrated spotlight is controllable 150 | elif (camera.product_model in DEVICEMGMT_API_MODELS): await self._run_action_devicemgmt(camera, "floodlight", "0") # Some camera models use a diffrent api 151 | else: await self._set_property(camera, PropertyIDs.ACCESSORY.value, "2") 152 | 153 | # Garage door trigger uses run action on all models 154 | async def garage_door_open(self, camera: Camera): 155 | await self._run_action(camera, "garage_door_trigger") 156 | 157 | async def garage_door_close(self, camera: Camera): 158 | await self._run_action(camera, "garage_door_trigger") 159 | 160 | async def turn_on_notifications(self, camera: Camera): 161 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._set_toggle(camera, DeviceMgmtToggleProps.NOTIFICATION_TOGGLE.value, "1") 162 | else: await self._set_property(camera, PropertyIDs.NOTIFICATION.value, "1") 163 | 164 | async def turn_off_notifications(self, camera: Camera): 165 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._set_toggle(camera, DeviceMgmtToggleProps.NOTIFICATION_TOGGLE.value, "0") 166 | else: await self._set_property(camera, PropertyIDs.NOTIFICATION.value, "0") 167 | 168 | # Both properties need to be set on newer cams, older cameras seem to only react 169 | # to the first property but it doesnt hurt to set both 170 | async def turn_on_motion_detection(self, camera: Camera): 171 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._set_toggle(camera, DeviceMgmtToggleProps.EVENT_RECORDING_TOGGLE.value, "1") 172 | elif (camera.product_model in ["WVOD1", "HL_WCO2"]): await self._set_property_list(camera, [create_pid_pair(PropertyIDs.WCO_MOTION_DETECTION, "1")]) 173 | else: 174 | await self._set_property(camera, PropertyIDs.MOTION_DETECTION.value, "1") 175 | await self._set_property(camera, PropertyIDs.MOTION_DETECTION_TOGGLE.value, "1") 176 | 177 | async def turn_off_motion_detection(self, camera: Camera): 178 | if (camera.product_model in DEVICEMGMT_API_MODELS): await self._set_toggle(camera, DeviceMgmtToggleProps.EVENT_RECORDING_TOGGLE.value, "0") 179 | elif (camera.product_model in ["WVOD1", "HL_WCO2"]): await self._set_property_list(camera, [create_pid_pair(PropertyIDs.WCO_MOTION_DETECTION, "0")]) 180 | else: 181 | await self._set_property(camera, PropertyIDs.MOTION_DETECTION.value, "0") 182 | await self._set_property(camera, PropertyIDs.MOTION_DETECTION_TOGGLE.value, "0") 183 | -------------------------------------------------------------------------------- /src/wyzeapy/services/hms_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | from enum import Enum 7 | from typing import Optional 8 | 9 | from ..wyze_auth_lib import WyzeAuthLib 10 | from .base_service import BaseService 11 | 12 | 13 | class HMSMode(Enum): 14 | CHANGING = 'changing' 15 | DISARMED = 'disarm' 16 | AWAY = 'away' 17 | HOME = 'home' 18 | 19 | 20 | class HMSService(BaseService): 21 | async def update(self, hms_id: str): 22 | hms_mode = await self._monitoring_profile_state_status(hms_id) 23 | return HMSMode(hms_mode['message']) 24 | 25 | def __init__(self, auth_lib: WyzeAuthLib): 26 | super().__init__(auth_lib) 27 | 28 | self._hms_id = None 29 | 30 | @classmethod 31 | async def create(cls, auth_lib: WyzeAuthLib): 32 | hms_service = cls(auth_lib) 33 | hms_service._hms_id = await hms_service._get_hms_id() 34 | 35 | return hms_service 36 | 37 | @property 38 | def hms_id(self) -> Optional[str]: 39 | return self._hms_id 40 | 41 | @property 42 | async def has_hms(self): 43 | if self._hms_id is None: 44 | self._hms_id = self.hms_id 45 | 46 | return self._hms_id is not None 47 | 48 | async def set_mode(self, mode: HMSMode): 49 | if mode == HMSMode.DISARMED: 50 | await self._disable_reme_alarm(self.hms_id) 51 | await self._monitoring_profile_active(self.hms_id, 0, 0) 52 | elif mode == HMSMode.AWAY: 53 | await self._monitoring_profile_active(self.hms_id, 0, 1) 54 | elif mode == HMSMode.HOME: 55 | await self._monitoring_profile_active(self.hms_id, 1, 0) 56 | 57 | async def _get_hms_id(self) -> Optional[str]: 58 | """ 59 | Processes the response from _get_plan_binding_list_by_user to get the hms_id 60 | 61 | :return: The hms_id or nothing if there is no hms in the account 62 | """ 63 | 64 | await self._auth_lib.refresh_if_should() 65 | 66 | if self._hms_id is not None: 67 | return self._hms_id 68 | 69 | response = await self._get_plan_binding_list_by_user() 70 | hms_subs = response['data'] 71 | 72 | if len(hms_subs) >= 1: 73 | for sub in hms_subs: 74 | if (devices := sub.get('deviceList')) is not None and len(devices) >= 1: 75 | for device in devices: 76 | self._hms_id = str(device['device_id']) 77 | return self._hms_id 78 | 79 | return None 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/wyzeapy/services/lock_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | from .base_service import BaseService 7 | from ..const import FORD_APP_SECRET 8 | from ..types import Device, DeviceTypes 9 | from ..utils import wyze_decrypt_cbc 10 | 11 | 12 | class Lock(Device): 13 | unlocked = False 14 | locking = False 15 | unlocking = False 16 | door_open = False 17 | trash_mode = False 18 | ble_id = None 19 | ble_token = None 20 | 21 | 22 | class LockService(BaseService): 23 | async def update(self, lock: Lock): 24 | device_info = await self._get_lock_info(lock) 25 | lock.raw_dict = device_info["device"] 26 | if lock.product_model == "YD_BT1": 27 | ble_token_info = await self._get_lock_ble_token(lock) 28 | lock.raw_dict["token"] = ble_token_info["token"] 29 | lock.ble_id = ble_token_info["token"]["id"] 30 | lock.ble_token = wyze_decrypt_cbc(FORD_APP_SECRET[:16], ble_token_info["token"]["token"]) 31 | 32 | lock.available = lock.raw_dict.get("onoff_line") == 1 33 | lock.door_open = lock.raw_dict.get("door_open_status") == 1 34 | lock.trash_mode = lock.raw_dict.get("trash_mode") == 1 35 | 36 | # store the nested dict for easier reference below 37 | locker_status = lock.raw_dict.get("locker_status") 38 | # Check if the door is locked 39 | lock.unlocked = locker_status.get("hardlock") == 2 40 | 41 | # Reset unlocking and locking if needed 42 | if lock.unlocked and lock.unlocking: 43 | lock.unlocking = False 44 | if not lock.unlocked and lock.locking: 45 | lock.locking = False 46 | 47 | return lock 48 | 49 | async def get_locks(self): 50 | if self._devices is None: 51 | self._devices = await self.get_object_list() 52 | 53 | locks = [device for device in self._devices if device.type is DeviceTypes.LOCK] 54 | 55 | return [Lock(device.raw_dict) for device in locks] 56 | 57 | async def lock(self, lock: Lock): 58 | await self._lock_control(lock, "remoteLock") 59 | 60 | async def unlock(self, lock: Lock): 61 | await self._lock_control(lock, "remoteUnlock") 62 | -------------------------------------------------------------------------------- /src/wyzeapy/services/sensor_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import asyncio 7 | import logging 8 | from threading import Thread 9 | from typing import List, Callable, Tuple, Optional 10 | 11 | from aiohttp import ClientOSError, ContentTypeError 12 | 13 | from ..exceptions import UnknownApiError 14 | from .base_service import BaseService 15 | from ..types import Device, PropertyIDs, DeviceTypes 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | class Sensor(Device): 21 | detected: bool = False 22 | 23 | 24 | class SensorService(BaseService): 25 | _updater_thread: Optional[Thread] = None 26 | _subscribers: List[Tuple[Sensor, Callable[[Sensor], None]]] = [] 27 | 28 | async def update(self, sensor: Sensor) -> Sensor: 29 | # Get updated device_params 30 | async with BaseService._update_lock: 31 | sensor.device_params = await self.get_updated_params(sensor.mac) 32 | properties = await self._get_device_info(sensor) 33 | 34 | for property in properties['data']['property_list']: 35 | pid = property['pid'] 36 | value = property['value'] 37 | 38 | try: 39 | if PropertyIDs(pid) == PropertyIDs.CONTACT_STATE: 40 | sensor.detected = value == "1" 41 | if PropertyIDs(pid) == PropertyIDs.MOTION_STATE: 42 | sensor.detected = value == "1" 43 | except ValueError: 44 | pass 45 | 46 | return sensor 47 | 48 | async def register_for_updates(self, sensor: Sensor, callback: Callable[[Sensor], None]): 49 | _LOGGER.debug(f"Registering sensor: {sensor.nickname} for updates") 50 | loop = asyncio.get_event_loop() 51 | if not self._updater_thread: 52 | self._updater_thread = Thread(target=self.update_worker, args=[loop, ], daemon=True) 53 | self._updater_thread.start() 54 | 55 | self._subscribers.append((sensor, callback)) 56 | 57 | async def deregister_for_updates(self, sensor: Sensor): 58 | self._subscribers = [(sense, callback) for sense, callback in self._subscribers if sense.mac != sensor.mac] 59 | 60 | def update_worker(self, loop): 61 | while True: 62 | for sensor, callback in self._subscribers: 63 | _LOGGER.debug(f"Providing update for {sensor.nickname}") 64 | try: 65 | callback(asyncio.run_coroutine_threadsafe(self.update(sensor), loop).result()) 66 | except UnknownApiError as e: 67 | _LOGGER.warning(f"The update method detected an UnknownApiError: {e}") 68 | except ClientOSError as e: 69 | _LOGGER.error(f"A network error was detected: {e}") 70 | except ContentTypeError as e: 71 | _LOGGER.error(f"Server returned unexpected ContentType: {e}") 72 | 73 | async def get_sensors(self) -> List[Sensor]: 74 | if self._devices is None: 75 | self._devices = await self.get_object_list() 76 | 77 | sensors = [Sensor(device.raw_dict) for device in self._devices if 78 | device.type is DeviceTypes.MOTION_SENSOR or 79 | device.type is DeviceTypes.CONTACT_SENSOR] 80 | return [Sensor(sensor.raw_dict) for sensor in sensors] 81 | -------------------------------------------------------------------------------- /src/wyzeapy/services/switch_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | from typing import List, Dict, Any 7 | 8 | from .base_service import BaseService 9 | from ..types import Device, DeviceTypes, PropertyIDs 10 | from datetime import timedelta, datetime 11 | 12 | 13 | class Switch(Device): 14 | def __init__(self, dictionary: Dict[Any, Any]): 15 | super().__init__(dictionary) 16 | self.on: bool = False 17 | 18 | 19 | class SwitchService(BaseService): 20 | async def update(self, switch: Switch): 21 | # Get updated device_params 22 | async with BaseService._update_lock: 23 | switch.device_params = await self.get_updated_params(switch.mac) 24 | 25 | device_info = await self._get_property_list(switch) 26 | 27 | for property_id, value in device_info: 28 | if property_id == PropertyIDs.ON: 29 | switch.on = value == "1" 30 | elif property_id == PropertyIDs.AVAILABLE: 31 | switch.available = value == "1" 32 | 33 | return switch 34 | 35 | async def get_switches(self) -> List[Switch]: 36 | if self._devices is None: 37 | self._devices = await self.get_object_list() 38 | 39 | devices = [device for device in self._devices if device.type is DeviceTypes.PLUG or 40 | device.type is DeviceTypes.OUTDOOR_PLUG] 41 | return [Switch(switch.raw_dict) for switch in devices] 42 | 43 | async def turn_on(self, switch: Switch): 44 | await self._set_property(switch, PropertyIDs.ON.value, "1") 45 | 46 | async def turn_off(self, switch: Switch): 47 | await self._set_property(switch, PropertyIDs.ON.value, "0") 48 | 49 | 50 | class SwitchUsageService(SwitchService): 51 | """Class to retrieve the last 25 hours of usage data.""" 52 | 53 | async def update(self, device: Device): 54 | start_time = int( 55 | datetime.timestamp((datetime.now() - timedelta(hours=25))) * 1000 56 | ) 57 | end_time = int(datetime.timestamp(datetime.now()) * 1000) 58 | 59 | device.usage_history = await self._get_plug_history( 60 | device, start_time, end_time 61 | ) 62 | 63 | return device 64 | -------------------------------------------------------------------------------- /src/wyzeapy/services/thermostat_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import logging 7 | from enum import Enum 8 | from typing import Any, Dict, List 9 | 10 | from .base_service import BaseService 11 | from ..types import Device, ThermostatProps, DeviceTypes 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class HVACMode(Enum): 17 | AUTO = "auto" 18 | HEAT = "heat" 19 | COOL = "cool" 20 | OFF = "off" 21 | 22 | 23 | class FanMode(Enum): # auto, on 24 | AUTO = "auto" 25 | ON = "on" 26 | 27 | 28 | class TemperatureUnit(Enum): 29 | FAHRENHEIT = "F" 30 | CELSIUS = "C" 31 | 32 | 33 | class Preset(Enum): 34 | HOME = "home" 35 | AWAY = "away" 36 | SLEEP = "sleep" 37 | 38 | 39 | class HVACState(Enum): 40 | COOLING = 'cooling' 41 | HEATING = 'heating' 42 | IDLE = 'idle' 43 | 44 | 45 | class Thermostat(Device): 46 | def __init__(self, dictionary: Dict[Any, Any]): 47 | super().__init__(dictionary) 48 | 49 | self.temp_unit: TemperatureUnit = TemperatureUnit.FAHRENHEIT 50 | self.cool_set_point: int = 74 51 | self.heat_set_point: int = 64 52 | self.fan_mode: FanMode = FanMode.AUTO 53 | self.hvac_mode: HVACMode = HVACMode.AUTO 54 | self.preset: Preset = Preset.HOME 55 | self.temperature: float = 71 56 | self.available: bool = True 57 | self.humidity: int = 50 58 | self.hvac_state: HVACState = HVACState.IDLE 59 | 60 | 61 | class ThermostatService(BaseService): 62 | async def update(self, thermostat: Thermostat) -> Thermostat: 63 | properties = (await self._thermostat_get_iot_prop(thermostat))['data']['props'] 64 | 65 | device_props = [] 66 | for property in properties: 67 | try: 68 | prop = ThermostatProps(property) 69 | device_props.append((prop, properties[property])) 70 | except ValueError as e: 71 | _LOGGER.debug(f"{e} with value {properties[property]}") 72 | 73 | thermostat_props = device_props 74 | for prop, value in thermostat_props: 75 | if prop == ThermostatProps.TEMP_UNIT: 76 | thermostat.temp_unit = TemperatureUnit(value) 77 | elif prop == ThermostatProps.COOL_SP: 78 | thermostat.cool_set_point = int(value) 79 | elif prop == ThermostatProps.HEAT_SP: 80 | thermostat.heat_set_point = int(value) 81 | elif prop == ThermostatProps.FAN_MODE: 82 | thermostat.fan_mode = FanMode(value) 83 | elif prop == ThermostatProps.MODE_SYS: 84 | thermostat.hvac_mode = HVACMode(value) 85 | elif prop == ThermostatProps.CURRENT_SCENARIO: 86 | thermostat.preset = Preset(value) 87 | elif prop == ThermostatProps.TEMPERATURE: 88 | thermostat.temperature = float(value) 89 | elif prop == ThermostatProps.IOT_STATE: 90 | thermostat.available = value == 'connected' 91 | elif prop == ThermostatProps.HUMIDITY: 92 | thermostat.humidity = int(value) 93 | elif prop == ThermostatProps.WORKING_STATE: 94 | thermostat.hvac_state = HVACState(value) 95 | 96 | return thermostat 97 | 98 | async def get_thermostats(self) -> List[Thermostat]: 99 | if self._devices is None: 100 | self._devices = await self.get_object_list() 101 | 102 | thermostats = [device for device in self._devices if device.type is DeviceTypes.THERMOSTAT] 103 | 104 | return [Thermostat(thermostat.raw_dict) for thermostat in thermostats] 105 | 106 | async def set_cool_point(self, thermostat: Device, temp: int): 107 | await self._thermostat_set_iot_prop(thermostat, ThermostatProps.COOL_SP, temp) 108 | 109 | async def set_heat_point(self, thermostat: Device, temp: int): 110 | await self._thermostat_set_iot_prop(thermostat, ThermostatProps.HEAT_SP, temp) 111 | 112 | async def set_hvac_mode(self, thermostat: Device, hvac_mode: HVACMode): 113 | await self._thermostat_set_iot_prop(thermostat, ThermostatProps.MODE_SYS, hvac_mode.value) 114 | 115 | async def set_fan_mode(self, thermostat: Device, fan_mode: FanMode): 116 | await self._thermostat_set_iot_prop(thermostat, ThermostatProps.FAN_MODE, fan_mode.value) 117 | 118 | async def set_preset(self, thermostat: Thermostat, preset: Preset): 119 | await self._thermostat_set_iot_prop(thermostat, ThermostatProps.CURRENT_SCENARIO, preset.value) 120 | 121 | async def _thermostat_get_iot_prop(self, device: Device) -> Dict[Any, Any]: 122 | url = "https://wyze-earth-service.wyzecam.com/plugin/earth/get_iot_prop" 123 | keys = 'trigger_off_val,emheat,temperature,humidity,time2temp_val,protect_time,mode_sys,heat_sp,cool_sp,' \ 124 | 'current_scenario,config_scenario,temp_unit,fan_mode,iot_state,w_city_id,w_lat,w_lon,working_state,' \ 125 | 'dev_hold,dev_holdtime,asw_hold,app_version,setup_state,wiring_logic_id,save_comfort_balance,' \ 126 | 'kid_lock,calibrate_humidity,calibrate_temperature,fancirc_time,query_schedule' 127 | return await self._get_iot_prop(url, device, keys) 128 | 129 | async def _thermostat_set_iot_prop(self, device: Device, prop: ThermostatProps, value: Any) -> None: 130 | url = "https://wyze-earth-service.wyzecam.com/plugin/earth/set_iot_prop_by_topic" 131 | return await self._set_iot_prop(url, device, prop.value, value) 132 | -------------------------------------------------------------------------------- /src/wyzeapy/services/update_manager.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | from dataclasses import dataclass, field 3 | from heapq import heappush, heappop 4 | from typing import Any 5 | from math import ceil 6 | from ..types import Device 7 | import logging 8 | import threading 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | INTERVAL = 300 13 | MAX_SLOTS = 225 14 | 15 | @dataclass(order=True) 16 | class DeviceUpdater(object): 17 | device: Device=field(compare=False) # The device that will be updated 18 | service: Any=field(compare=False) 19 | update_in: int # A countdown to zero that will tell the priority queue that it is time to update this device 20 | updates_per_interval: int=field(compare=False) # The number of updates that should happen every 5 minutes 21 | 22 | def __init__(self, service, device: Device, update_interval: int): 23 | """ 24 | This function initializes a DeviceUpdater object 25 | :param service: The WyzeApy service connected to a device 26 | :param device: A WyzeApy device that needs to be in the update que 27 | :param update_interval: How many seconds should be targeted between updates. **Note this value may shift based on 1 call per sec and load. 28 | """ 29 | self.service = service 30 | self.device = device 31 | self.update_in = 0 # Always initialize at 0 so that we get the first update ASAP. The items will shift based on priority after this. 32 | self.updates_per_interval = ceil(INTERVAL / update_interval) 33 | 34 | async def update(self, mutex: threading.Lock): 35 | # We only want to update if the update_in counter is zero 36 | if self.update_in <= 0: 37 | _LOGGER.debug("Updating device: " + self.device.nickname) 38 | # Acquire the mutex before making the async call 39 | mutex.acquire() 40 | try: 41 | # Get the updated info for the device from Wyze's API 42 | self.device = await self.service.update(self.device) 43 | # Callback to provide the updated info to the subscriber 44 | self.device.callback_function(self.device) 45 | except: 46 | _LOGGER.exception("Unknown error happened during updating device info") 47 | finally: 48 | # Release the mutex after the async call 49 | mutex.release() 50 | # Once it reaches zero and we update the device we want to reset the update_in counter 51 | self.update_in = ceil(INTERVAL / self.updates_per_interval) 52 | else: 53 | # Don't update and instead just reduce the counter by 1 54 | self.tick_tock() 55 | 56 | def tick_tock(self): 57 | # Every time we update a device we want to reduce the update_in counter so that it will get closer to updating 58 | if self.update_in > 0: 59 | self.update_in -= 1 60 | 61 | def delay(self): 62 | # This should be called to reduce the number of updates per interval so that new devices can be added into the queue fairly 63 | if self.updates_per_interval > 1: 64 | self.updates_per_interval -= 1 65 | 66 | class UpdateManager: 67 | # Holds all the logic for when to update the devices 68 | updaters = [] 69 | removed_updaters = [] 70 | mutex = threading.Lock() # Create a lock object as a class variable 71 | 72 | def check_if_removed(self, updater: DeviceUpdater): 73 | for item in self.removed_updaters: 74 | if updater is item: 75 | return True 76 | return False 77 | 78 | # This function should be called once every second 79 | async def update_next(self): 80 | # If there are no updaters in the queue we don't need to do anything 81 | if (len(self.updaters) == 0): 82 | _LOGGER.debug("No devices to update in queue") 83 | return 84 | while True: 85 | # First we get the next updater off the queue 86 | updater = heappop(self.updaters) 87 | # if the updater has been removed, pop the next and clear it from the removed updaters 88 | if self.removed_updaters: 89 | while self.check_if_removed(updater): 90 | self.removed_updaters.remove(updater) 91 | updater = heappop(self.updaters) 92 | # We then reduce the counter for all the other updaters 93 | self.tick_tock() 94 | # Then we update the target device 95 | await updater.update(self.mutex) # It will only update if it is time for it to update. Otherwise it just reduces its update_in counter. 96 | # Then we put it back at the end of the queue. Or the front again if it wasn't ready to update 97 | heappush(self.updaters, updater) 98 | await sleep(1) 99 | 100 | 101 | def filled_slots(self): 102 | # This just returns the number of available slots 103 | current_slots = 0 104 | for a_updater in self.updaters: 105 | current_slots += a_updater.updates_per_interval 106 | 107 | return current_slots 108 | 109 | def decrease_updates_per_interval(self): 110 | # This will add a delay for all devices so we can squeeze more in there 111 | for a_updater in self.updaters: 112 | a_updater.delay() 113 | 114 | def tick_tock(self): 115 | # This will reduce the update_in counter for all devices 116 | for a_updater in self.updaters: 117 | a_updater.tick_tock() 118 | 119 | def add_updater(self, updater: DeviceUpdater): 120 | if len(self.updaters) >= MAX_SLOTS: 121 | _LOGGER.exception("No more devices can be updated within the rate limit") 122 | raise Exception("No more devices can be updated within the rate limit") 123 | 124 | # When we add a new updater it has to fit within the max slots or we will not add it 125 | while (self.filled_slots() + updater.updates_per_interval) > MAX_SLOTS: 126 | _LOGGER.debug("Reducing updates per interval to fit new device as slots are full: %s", self.filled_slots()) 127 | # If we are overflowing the available slots we will reduce the frequency of updates evenly for all devices until we can fit in one more. 128 | self.decrease_updates_per_interval() 129 | updater.delay() 130 | 131 | # Once it fits we will add the new updater to the queue 132 | heappush(self.updaters, updater) 133 | 134 | def del_updater(self, updater: DeviceUpdater): 135 | self.removed_updaters.append(updater) 136 | _LOGGER.debug("Removing device from update queue") 137 | -------------------------------------------------------------------------------- /src/wyzeapy/services/wall_switch_service.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import logging 7 | from enum import Enum 8 | from typing import Any, Dict, List 9 | 10 | from .base_service import BaseService 11 | from ..types import Device, WallSwitchProps, DeviceTypes 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class SinglePressType(Enum): 17 | CLASSIC = 1 18 | IOT = 2 19 | 20 | 21 | class WallSwitch(Device): 22 | def __init__(self, dictionary: Dict[Any, Any]): 23 | super().__init__(dictionary) 24 | 25 | self.switch_power: bool = False 26 | self.switch_iot: bool = False 27 | self.single_press_type: SinglePressType = SinglePressType.CLASSIC 28 | 29 | @property 30 | def on(self): 31 | if self.single_press_type == SinglePressType.IOT: 32 | return self.switch_iot 33 | return self.switch_power 34 | 35 | @on.setter 36 | def on(self, state: bool): 37 | if self.single_press_type == SinglePressType.IOT: 38 | self.switch_iot = state 39 | self.switch_power = state 40 | 41 | 42 | class WallSwitchService(BaseService): 43 | async def update(self, switch: WallSwitch) -> WallSwitch: 44 | properties = (await self._wall_switch_get_iot_prop(switch))['data']['props'] 45 | 46 | device_props = [] 47 | for prop_key, prop_value in properties.items(): 48 | try: 49 | prop = WallSwitchProps(prop_key) 50 | device_props.append((prop, prop_value)) 51 | except ValueError as e: 52 | _LOGGER.debug(f"{e} with value {prop_value}") 53 | 54 | for prop, value in device_props: 55 | if prop == WallSwitchProps.IOT_STATE: 56 | switch.available = value == "connected" 57 | elif prop == WallSwitchProps.SWITCH_POWER: 58 | switch.switch_power = value 59 | elif prop == WallSwitchProps.SWITCH_IOT: 60 | switch.switch_iot = value 61 | elif prop == WallSwitchProps.SINGLE_PRESS_TYPE: 62 | switch.single_press_type = SinglePressType(value) 63 | 64 | return switch 65 | 66 | async def get_switches(self) -> List[WallSwitch]: 67 | if self._devices is None: 68 | self._devices = await self.get_object_list() 69 | 70 | switches = [device for device in self._devices 71 | if device.type is DeviceTypes.COMMON 72 | and device.product_model == "LD_SS1"] 73 | 74 | return [WallSwitch(switch.raw_dict) for switch in switches] 75 | 76 | async def turn_on(self, switch: WallSwitch): 77 | if switch.single_press_type == SinglePressType.IOT: 78 | await self.iot_on(switch) 79 | else: 80 | await self.power_on(switch) 81 | 82 | async def turn_off(self, switch: WallSwitch): 83 | if switch.single_press_type == SinglePressType.IOT: 84 | await self.iot_off(switch) 85 | else: 86 | await self.power_off(switch) 87 | 88 | async def power_on(self, switch: WallSwitch): 89 | await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_POWER, True) 90 | 91 | async def power_off(self, switch: WallSwitch): 92 | await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_POWER, False) 93 | 94 | async def iot_on(self, switch: WallSwitch): 95 | await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_IOT, True) 96 | 97 | async def iot_off(self, switch: WallSwitch): 98 | await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SWITCH_IOT, False) 99 | 100 | async def set_single_press_type(self, switch: WallSwitch, single_press_type: SinglePressType): 101 | await self._wall_switch_set_iot_prop(switch, WallSwitchProps.SINGLE_PRESS_TYPE, single_press_type.value) 102 | 103 | async def _wall_switch_get_iot_prop(self, device: Device) -> Dict[Any, Any]: 104 | url = "https://wyze-sirius-service.wyzecam.com//plugin/sirius/get_iot_prop" 105 | keys = "iot_state,switch-power,switch-iot,single_press_type" 106 | return await self._get_iot_prop(url, device, keys) 107 | 108 | async def _wall_switch_set_iot_prop(self, device: Device, prop: WallSwitchProps, value: Any) -> None: 109 | url = "https://wyze-sirius-service.wyzecam.com//plugin/sirius/set_iot_prop_by_topic" 110 | return await self._set_iot_prop(url, device, prop.value, value) 111 | -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_bulb_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.bulb_service import BulbService, Bulb 4 | from wyzeapy.types import DeviceTypes, PropertyIDs 5 | 6 | 7 | class TestBulbService(unittest.IsolatedAsyncioTestCase): 8 | async def asyncSetUp(self): 9 | mock_auth_lib = MagicMock() 10 | self.bulb_service = BulbService(auth_lib=mock_auth_lib) 11 | self.bulb_service._get_property_list = AsyncMock() 12 | self.bulb_service.get_updated_params = AsyncMock() 13 | 14 | async def test_update_bulb_basic_properties(self): 15 | mock_bulb = Bulb({ 16 | "device_type": "Light", 17 | "product_model": "WLPA19", 18 | "mac": "TEST123", 19 | "raw_dict": {}, 20 | "device_params": {"ip": "192.168.1.100"}, 21 | "prop_map": {}, 22 | 'product_type': DeviceTypes.MESH_LIGHT.value 23 | }) 24 | 25 | # Mock the property list response 26 | self.bulb_service._get_property_list.return_value = [ 27 | (PropertyIDs.BRIGHTNESS, "75"), 28 | (PropertyIDs.COLOR_TEMP, "4000"), 29 | (PropertyIDs.ON, "1"), 30 | (PropertyIDs.AVAILABLE, "1") 31 | ] 32 | 33 | updated_bulb = await self.bulb_service.update(mock_bulb) 34 | 35 | self.assertEqual(updated_bulb.brightness, 75) 36 | self.assertEqual(updated_bulb.color_temp, 4000) 37 | self.assertTrue(updated_bulb.on) 38 | self.assertTrue(updated_bulb.available) 39 | 40 | async def test_update_bulb_lightstrip_properties(self): 41 | mock_bulb = Bulb({ 42 | "device_type": "Light", 43 | "product_model": "WLST19", 44 | "mac": "TEST456", 45 | "raw_dict": {}, 46 | "device_params": {"ip": "192.168.1.101"}, 47 | "prop_map": {}, 48 | 'product_type': DeviceTypes.LIGHTSTRIP.value 49 | }) 50 | mock_bulb.product_type = DeviceTypes.LIGHTSTRIP 51 | 52 | # Mock the property list response with the corrected color format (no # symbol) 53 | self.bulb_service._get_property_list.return_value = [ 54 | (PropertyIDs.COLOR, "FF0000"), # Removed the # symbol 55 | (PropertyIDs.COLOR_MODE, "1"), 56 | (PropertyIDs.LIGHTSTRIP_EFFECTS, "rainbow"), 57 | (PropertyIDs.LIGHTSTRIP_MUSIC_MODE, "1"), 58 | (PropertyIDs.ON, "1"), 59 | (PropertyIDs.AVAILABLE, "1") 60 | ] 61 | 62 | updated_bulb = await self.bulb_service.update(mock_bulb) 63 | 64 | self.assertEqual(updated_bulb.color, "FF0000") 65 | self.assertEqual(updated_bulb.color_mode, "1") 66 | self.assertEqual(updated_bulb.effects, "rainbow") 67 | self.assertTrue(updated_bulb.music_mode) 68 | self.assertTrue(updated_bulb.on) 69 | self.assertTrue(updated_bulb.available) 70 | 71 | async def test_update_bulb_sun_match(self): 72 | mock_bulb = Bulb({ 73 | "device_type": "Light", 74 | "product_model": "WLPA19", 75 | "mac": "TEST789", 76 | "raw_dict": {}, 77 | "device_params": {"ip": "192.168.1.102"}, 78 | "prop_map": {}, 79 | 'product_type': DeviceTypes.MESH_LIGHT.value 80 | }) 81 | 82 | # Mock the property list response 83 | self.bulb_service._get_property_list.return_value = [ 84 | (PropertyIDs.SUN_MATCH, "1"), 85 | (PropertyIDs.ON, "1"), 86 | (PropertyIDs.AVAILABLE, "1") 87 | ] 88 | 89 | updated_bulb = await self.bulb_service.update(mock_bulb) 90 | 91 | self.assertTrue(updated_bulb.sun_match) 92 | self.assertTrue(updated_bulb.on) 93 | self.assertTrue(updated_bulb.available) 94 | 95 | async def test_update_bulb_invalid_color_temp(self): 96 | mock_bulb = Bulb({ 97 | "device_type": "Light", 98 | "product_model": "WLPA19", 99 | "mac": "TEST101", 100 | "raw_dict": {}, 101 | "device_params": {"ip": "192.168.1.103"}, 102 | "prop_map": {}, 103 | 'product_type': DeviceTypes.MESH_LIGHT.value 104 | }) 105 | 106 | # Mock the property list response with invalid color temp 107 | self.bulb_service._get_property_list.return_value = [ 108 | (PropertyIDs.COLOR_TEMP, "invalid"), 109 | (PropertyIDs.ON, "1") 110 | ] 111 | 112 | updated_bulb = await self.bulb_service.update(mock_bulb) 113 | 114 | # Should default to 2700K when invalid 115 | self.assertEqual(updated_bulb.color_temp, 2700) 116 | self.assertTrue(updated_bulb.on) 117 | 118 | async def test_get_bulbs(self): 119 | mock_device = MagicMock() 120 | mock_device.type = DeviceTypes.LIGHT 121 | mock_device.raw_dict = { 122 | "device_type": "Light", 123 | "product_model": "WLPA19", 124 | "device_params": {"ip": "192.168.1.104"}, 125 | "prop_map": {}, 126 | 'product_type': DeviceTypes.MESH_LIGHT.value 127 | } 128 | 129 | self.bulb_service.get_object_list = AsyncMock(return_value=[mock_device]) 130 | 131 | bulbs = await self.bulb_service.get_bulbs() 132 | 133 | self.assertEqual(len(bulbs), 1) 134 | self.assertIsInstance(bulbs[0], Bulb) 135 | self.bulb_service.get_object_list.assert_awaited_once() -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_camera_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.camera_service import CameraService, Camera, DEVICEMGMT_API_MODELS 4 | from wyzeapy.types import DeviceTypes, PropertyIDs, Event 5 | from wyzeapy.wyze_auth_lib import WyzeAuthLib 6 | 7 | 8 | class TestCameraService(unittest.IsolatedAsyncioTestCase): 9 | async def asyncSetUp(self): 10 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 11 | self.camera_service = CameraService(auth_lib=self.mock_auth_lib) 12 | self.camera_service._get_property_list = AsyncMock() 13 | self.camera_service._get_event_list = AsyncMock() 14 | self.camera_service._run_action = AsyncMock() 15 | self.camera_service._run_action_devicemgmt = AsyncMock() 16 | self.camera_service._set_property = AsyncMock() 17 | self.camera_service._set_property_list = AsyncMock() 18 | self.camera_service._set_toggle = AsyncMock() 19 | self.camera_service.get_updated_params = AsyncMock() 20 | 21 | # Create a test camera 22 | self.test_camera = Camera({ 23 | "device_type": DeviceTypes.CAMERA.value, 24 | "product_model": "WYZEC1", 25 | "mac": "TEST123", 26 | "nickname": "Test Camera", 27 | "device_params": {"ip": "192.168.1.100"}, 28 | "raw_dict": {} 29 | }) 30 | 31 | async def test_update_legacy_camera(self): 32 | # Mock responses 33 | self.camera_service._get_event_list.return_value = { 34 | 'data': { 35 | 'event_list': [{ 36 | 'event_ts': 1234567890, 37 | 'device_mac': 'TEST123', 38 | 'event_type': 'motion' 39 | }] 40 | } 41 | } 42 | 43 | self.camera_service._get_property_list.return_value = [ 44 | (PropertyIDs.AVAILABLE, "1"), 45 | (PropertyIDs.ON, "1"), 46 | (PropertyIDs.CAMERA_SIREN, "0"), 47 | (PropertyIDs.ACCESSORY, "0"), 48 | (PropertyIDs.NOTIFICATION, "1"), 49 | (PropertyIDs.MOTION_DETECTION, "1") 50 | ] 51 | 52 | updated_camera = await self.camera_service.update(self.test_camera) 53 | 54 | self.assertTrue(updated_camera.available) 55 | self.assertTrue(updated_camera.on) 56 | self.assertFalse(updated_camera.siren) 57 | self.assertFalse(updated_camera.floodlight) 58 | self.assertTrue(updated_camera.notify) 59 | self.assertTrue(updated_camera.motion) 60 | self.assertIsNotNone(updated_camera.last_event) 61 | self.assertEqual(updated_camera.last_event_ts, 1234567890) 62 | 63 | async def test_update_devicemgmt_camera(self): 64 | # Create a test camera using new API model 65 | devicemgmt_camera = Camera({ 66 | "device_type": DeviceTypes.CAMERA.value, 67 | "product_model": "LD_CFP", # Floodlight pro model 68 | "mac": "TEST456", 69 | "nickname": "Test DeviceMgmt Camera", 70 | "device_params": {"ip": "192.168.1.101"}, 71 | "raw_dict": {} 72 | }) 73 | 74 | self.camera_service._get_iot_prop_devicemgmt = AsyncMock(return_value={ 75 | 'data': { 76 | 'capabilities': [ 77 | { 78 | 'name': 'camera', 79 | 'properties': {'motion-detect-recording': True} 80 | }, 81 | { 82 | 'name': 'floodlight', 83 | 'properties': {'on': True} 84 | }, 85 | { 86 | 'name': 'siren', 87 | 'properties': {'state': True} 88 | }, 89 | { 90 | 'name': 'iot-device', 91 | 'properties': { 92 | 'push-switch': True, 93 | 'iot-power': True, 94 | 'iot-state': True 95 | } 96 | } 97 | ] 98 | } 99 | }) 100 | 101 | updated_camera = await self.camera_service.update(devicemgmt_camera) 102 | 103 | self.assertTrue(updated_camera.available) 104 | self.assertTrue(updated_camera.on) 105 | self.assertTrue(updated_camera.siren) 106 | self.assertTrue(updated_camera.floodlight) 107 | self.assertTrue(updated_camera.notify) 108 | self.assertTrue(updated_camera.motion) 109 | 110 | async def test_turn_on_off_legacy_camera(self): 111 | await self.camera_service.turn_on(self.test_camera) 112 | self.camera_service._run_action.assert_awaited_with(self.test_camera, "power_on") 113 | 114 | await self.camera_service.turn_off(self.test_camera) 115 | self.camera_service._run_action.assert_awaited_with(self.test_camera, "power_off") 116 | 117 | async def test_siren_control_legacy_camera(self): 118 | await self.camera_service.siren_on(self.test_camera) 119 | self.camera_service._run_action.assert_awaited_with(self.test_camera, "siren_on") 120 | 121 | await self.camera_service.siren_off(self.test_camera) 122 | self.camera_service._run_action.assert_awaited_with(self.test_camera, "siren_off") 123 | 124 | async def test_floodlight_control_legacy_camera(self): 125 | await self.camera_service.floodlight_on(self.test_camera) 126 | self.camera_service._set_property.assert_awaited_with( 127 | self.test_camera, 128 | PropertyIDs.ACCESSORY.value, 129 | "1" 130 | ) 131 | 132 | await self.camera_service.floodlight_off(self.test_camera) 133 | self.camera_service._set_property.assert_awaited_with( 134 | self.test_camera, 135 | PropertyIDs.ACCESSORY.value, 136 | "2" 137 | ) 138 | 139 | async def test_notification_control_legacy_camera(self): 140 | await self.camera_service.turn_on_notifications(self.test_camera) 141 | self.camera_service._set_property.assert_awaited_with( 142 | self.test_camera, 143 | PropertyIDs.NOTIFICATION.value, 144 | "1" 145 | ) 146 | 147 | await self.camera_service.turn_off_notifications(self.test_camera) 148 | self.camera_service._set_property.assert_awaited_with( 149 | self.test_camera, 150 | PropertyIDs.NOTIFICATION.value, 151 | "0" 152 | ) 153 | 154 | async def test_motion_detection_control_legacy_camera(self): 155 | await self.camera_service.turn_on_motion_detection(self.test_camera) 156 | self.camera_service._set_property.assert_any_await( 157 | self.test_camera, 158 | PropertyIDs.MOTION_DETECTION.value, 159 | "1" 160 | ) 161 | self.camera_service._set_property.assert_any_await( 162 | self.test_camera, 163 | PropertyIDs.MOTION_DETECTION_TOGGLE.value, 164 | "1" 165 | ) 166 | 167 | await self.camera_service.turn_off_motion_detection(self.test_camera) 168 | self.camera_service._set_property.assert_any_await( 169 | self.test_camera, 170 | PropertyIDs.MOTION_DETECTION.value, 171 | "0" 172 | ) 173 | self.camera_service._set_property.assert_any_await( 174 | self.test_camera, 175 | PropertyIDs.MOTION_DETECTION_TOGGLE.value, 176 | "0" 177 | ) 178 | 179 | if __name__ == '__main__': 180 | unittest.main() -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_hms_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.hms_service import HMSService, HMSMode 4 | from wyzeapy.wyze_auth_lib import WyzeAuthLib 5 | 6 | class TestHMSService(unittest.IsolatedAsyncioTestCase): 7 | async def asyncSetUp(self): 8 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 9 | self.hms_service = await HMSService.create(self.mock_auth_lib) 10 | self.hms_service._get_plan_binding_list_by_user = AsyncMock() 11 | self.hms_service._monitoring_profile_state_status = AsyncMock() 12 | self.hms_service._monitoring_profile_active = AsyncMock() 13 | self.hms_service._disable_reme_alarm = AsyncMock() 14 | 15 | async def test_update_changing_mode(self): 16 | self.hms_service._monitoring_profile_state_status.return_value = {'message': 'changing'} 17 | 18 | mode = await self.hms_service.update('test_hms_id') 19 | self.assertEqual(mode, HMSMode.CHANGING) 20 | 21 | async def test_update_disarmed_mode(self): 22 | self.hms_service._monitoring_profile_state_status.return_value = {'message': 'disarm'} 23 | 24 | mode = await self.hms_service.update('test_hms_id') 25 | self.assertEqual(mode, HMSMode.DISARMED) 26 | 27 | async def test_update_away_mode(self): 28 | self.hms_service._monitoring_profile_state_status.return_value = {'message': 'away'} 29 | 30 | mode = await self.hms_service.update('test_hms_id') 31 | self.assertEqual(mode, HMSMode.AWAY) 32 | 33 | async def test_update_home_mode(self): 34 | self.hms_service._monitoring_profile_state_status.return_value = {'message': 'home'} 35 | 36 | mode = await self.hms_service.update('test_hms_id') 37 | self.assertEqual(mode, HMSMode.HOME) 38 | 39 | async def test_set_mode_disarmed(self): 40 | self.hms_service._hms_id = 'test_hms_id' 41 | 42 | await self.hms_service.set_mode(HMSMode.DISARMED) 43 | 44 | self.hms_service._disable_reme_alarm.assert_awaited_with('test_hms_id') 45 | self.hms_service._monitoring_profile_active.assert_awaited_with('test_hms_id', 0, 0) 46 | 47 | async def test_set_mode_away(self): 48 | self.hms_service._hms_id = 'test_hms_id' 49 | 50 | await self.hms_service.set_mode(HMSMode.AWAY) 51 | 52 | self.hms_service._monitoring_profile_active.assert_awaited_with('test_hms_id', 0, 1) 53 | 54 | async def test_set_mode_home(self): 55 | self.hms_service._hms_id = 'test_hms_id' 56 | 57 | await self.hms_service.set_mode(HMSMode.HOME) 58 | 59 | self.hms_service._monitoring_profile_active.assert_awaited_with('test_hms_id', 1, 0) 60 | 61 | async def test_get_hms_id_with_existing_id(self): 62 | self.hms_service._hms_id = 'existing_hms_id' 63 | hms_id = await self.hms_service._get_hms_id() 64 | self.assertEqual(hms_id, 'existing_hms_id') 65 | 66 | async def test_get_hms_id_with_no_hms(self): 67 | self.hms_service._hms_id = None 68 | self.hms_service._get_plan_binding_list_by_user.return_value = {'data': []} 69 | 70 | hms_id = await self.hms_service._get_hms_id() 71 | self.assertIsNone(hms_id) 72 | 73 | async def test_get_hms_id_finds_id(self): 74 | self.hms_service._hms_id = None 75 | self.hms_service._get_plan_binding_list_by_user.return_value = { 76 | 'data': [ 77 | { 78 | 'deviceList': [ 79 | {'device_id': 'found_hms_id'} 80 | ] 81 | } 82 | ] 83 | } 84 | 85 | hms_id = await self.hms_service._get_hms_id() 86 | self.assertEqual(hms_id, 'found_hms_id') 87 | self.assertEqual(self.hms_service._hms_id, 'found_hms_id') 88 | 89 | if __name__ == '__main__': 90 | unittest.main() -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_lock_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.lock_service import LockService, Lock 4 | from wyzeapy.types import DeviceTypes 5 | from wyzeapy.exceptions import UnknownApiError 6 | 7 | class TestLockService(unittest.IsolatedAsyncioTestCase): 8 | async def asyncSetUp(self): 9 | mock_auth_lib = MagicMock() 10 | self.lock_service = LockService(auth_lib=mock_auth_lib) 11 | self.lock_service._get_lock_info = AsyncMock() 12 | self.lock_service._lock_control = AsyncMock() 13 | 14 | async def test_update_lock_online(self): 15 | mock_lock = Lock({ 16 | "device_type": "Lock", 17 | "onoff_line": 1, 18 | "door_open_status": 0, 19 | "trash_mode": 0, 20 | "locker_status": {"hardlock": 2}, 21 | "raw_dict": {} 22 | }) 23 | self.lock_service._get_lock_info.return_value = { 24 | "device": { 25 | "onoff_line": 1, 26 | "door_open_status": 0, 27 | "trash_mode": 0, 28 | "locker_status": {"hardlock": 2}, 29 | } 30 | } 31 | 32 | updated_lock = await self.lock_service.update(mock_lock) 33 | 34 | self.assertTrue(updated_lock.available) 35 | self.assertFalse(updated_lock.door_open) 36 | self.assertFalse(updated_lock.trash_mode) 37 | self.assertTrue(updated_lock.unlocked) 38 | self.assertFalse(updated_lock.unlocking) 39 | self.assertFalse(updated_lock.locking) 40 | self.lock_service._get_lock_info.assert_awaited_once_with(mock_lock) 41 | 42 | async def test_update_lock_offline(self): 43 | mock_lock = Lock({ 44 | "device_type": "Lock", 45 | "onoff_line": 0, 46 | "door_open_status": 1, 47 | "trash_mode": 1, 48 | "locker_status": {"hardlock": 1}, 49 | "raw_dict": {} 50 | }) 51 | self.lock_service._get_lock_info.return_value = { 52 | "device": { 53 | "onoff_line": 0, 54 | "door_open_status": 1, 55 | "trash_mode": 1, 56 | "locker_status": {"hardlock": 1}, 57 | } 58 | } 59 | 60 | updated_lock = await self.lock_service.update(mock_lock) 61 | 62 | self.assertFalse(updated_lock.available) 63 | self.assertTrue(updated_lock.door_open) 64 | self.assertTrue(updated_lock.trash_mode) 65 | self.assertFalse(updated_lock.unlocked) 66 | self.assertFalse(updated_lock.unlocking) 67 | self.assertFalse(updated_lock.locking) 68 | self.lock_service._get_lock_info.assert_awaited_once_with(mock_lock) 69 | 70 | async def test_get_locks(self): 71 | mock_device = AsyncMock() 72 | mock_device.type = DeviceTypes.LOCK 73 | mock_device.raw_dict = {"device_type": "Lock"} 74 | 75 | self.lock_service.get_object_list = AsyncMock(return_value=[mock_device]) 76 | 77 | locks = await self.lock_service.get_locks() 78 | 79 | self.assertEqual(len(locks), 1) 80 | self.assertIsInstance(locks[0], Lock) 81 | self.lock_service.get_object_list.assert_awaited_once() 82 | 83 | async def test_lock(self): 84 | mock_lock = Lock({ 85 | "device_type": "Lock", 86 | "raw_dict": {} 87 | }) 88 | 89 | await self.lock_service.lock(mock_lock) 90 | self.lock_service._lock_control.assert_awaited_with(mock_lock, "remoteLock") 91 | 92 | async def test_unlock(self): 93 | mock_lock = Lock({ 94 | "device_type": "Lock", 95 | "raw_dict": {} 96 | }) 97 | 98 | await self.lock_service.unlock(mock_lock) 99 | self.lock_service._lock_control.assert_awaited_with(mock_lock, "remoteUnlock") 100 | 101 | async def test_lock_control_error_handling(self): 102 | mock_lock = Lock({ 103 | "device_type": "Lock", 104 | "raw_dict": {} 105 | }) 106 | self.lock_service._lock_control.side_effect = UnknownApiError("Failed to lock/unlock") 107 | 108 | with self.assertRaises(UnknownApiError): 109 | await self.lock_service.lock(mock_lock) 110 | 111 | with self.assertRaises(UnknownApiError): 112 | await self.lock_service.unlock(mock_lock) 113 | 114 | # ... other test cases ... -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_sensor_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.sensor_service import SensorService, Sensor 4 | from wyzeapy.types import DeviceTypes, PropertyIDs 5 | from wyzeapy.wyze_auth_lib import WyzeAuthLib 6 | 7 | 8 | class TestSensorService(unittest.IsolatedAsyncioTestCase): 9 | async def asyncSetUp(self): 10 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 11 | self.sensor_service = SensorService(auth_lib=self.mock_auth_lib) 12 | self.sensor_service._get_device_info = AsyncMock() 13 | self.sensor_service.get_updated_params = AsyncMock() 14 | self.sensor_service.get_object_list = AsyncMock() 15 | 16 | # Reset the class-level subscribers list 17 | self.sensor_service._subscribers = [] 18 | 19 | # Create test sensors 20 | self.motion_sensor = Sensor({ 21 | "device_type": DeviceTypes.MOTION_SENSOR.value, 22 | "product_model": "PIR3U", 23 | "mac": "MOTION123", 24 | "nickname": "Test Motion Sensor", 25 | "device_params": {"ip": "192.168.1.100"}, 26 | "raw_dict": {} 27 | }) 28 | 29 | self.contact_sensor = Sensor({ 30 | "device_type": DeviceTypes.CONTACT_SENSOR.value, 31 | "product_model": "DWS3U", 32 | "mac": "CONTACT456", 33 | "nickname": "Test Contact Sensor", 34 | "device_params": {"ip": "192.168.1.101"}, 35 | "raw_dict": {} 36 | }) 37 | 38 | async def test_update_motion_sensor_detected(self): 39 | self.sensor_service._get_device_info.return_value = { 40 | 'data': { 41 | 'property_list': [ 42 | { 43 | 'pid': PropertyIDs.MOTION_STATE.value, 44 | 'value': '1' 45 | } 46 | ] 47 | } 48 | } 49 | 50 | updated_sensor = await self.sensor_service.update(self.motion_sensor) 51 | self.assertTrue(updated_sensor.detected) 52 | 53 | async def test_update_motion_sensor_not_detected(self): 54 | self.sensor_service._get_device_info.return_value = { 55 | 'data': { 56 | 'property_list': [ 57 | { 58 | 'pid': PropertyIDs.MOTION_STATE.value, 59 | 'value': '0' 60 | } 61 | ] 62 | } 63 | } 64 | 65 | updated_sensor = await self.sensor_service.update(self.motion_sensor) 66 | self.assertFalse(updated_sensor.detected) 67 | 68 | async def test_update_contact_sensor_detected(self): 69 | self.sensor_service._get_device_info.return_value = { 70 | 'data': { 71 | 'property_list': [ 72 | { 73 | 'pid': PropertyIDs.CONTACT_STATE.value, 74 | 'value': '1' 75 | } 76 | ] 77 | } 78 | } 79 | 80 | updated_sensor = await self.sensor_service.update(self.contact_sensor) 81 | self.assertTrue(updated_sensor.detected) 82 | 83 | async def test_update_contact_sensor_not_detected(self): 84 | self.sensor_service._get_device_info.return_value = { 85 | 'data': { 86 | 'property_list': [ 87 | { 88 | 'pid': PropertyIDs.CONTACT_STATE.value, 89 | 'value': '0' 90 | } 91 | ] 92 | } 93 | } 94 | 95 | updated_sensor = await self.sensor_service.update(self.contact_sensor) 96 | self.assertFalse(updated_sensor.detected) 97 | 98 | async def test_get_sensors(self): 99 | mock_motion_device = MagicMock() 100 | mock_motion_device.type = DeviceTypes.MOTION_SENSOR 101 | mock_motion_device.raw_dict = { 102 | "device_type": DeviceTypes.MOTION_SENSOR.value, 103 | "product_model": "PIR3U", 104 | "mac": "MOTION123" 105 | } 106 | 107 | mock_contact_device = MagicMock() 108 | mock_contact_device.type = DeviceTypes.CONTACT_SENSOR 109 | mock_contact_device.raw_dict = { 110 | "device_type": DeviceTypes.CONTACT_SENSOR.value, 111 | "product_model": "DWS3U", 112 | "mac": "CONTACT456" 113 | } 114 | 115 | self.sensor_service.get_object_list.return_value = [ 116 | mock_motion_device, 117 | mock_contact_device 118 | ] 119 | 120 | sensors = await self.sensor_service.get_sensors() 121 | 122 | self.assertEqual(len(sensors), 2) 123 | self.assertIsInstance(sensors[0], Sensor) 124 | self.assertIsInstance(sensors[1], Sensor) 125 | self.sensor_service.get_object_list.assert_awaited_once() 126 | 127 | async def test_register_for_updates(self): 128 | mock_callback = MagicMock() 129 | await self.sensor_service.register_for_updates(self.motion_sensor, mock_callback) 130 | 131 | self.assertEqual(len(self.sensor_service._subscribers), 1) 132 | self.assertEqual(self.sensor_service._subscribers[0][0], self.motion_sensor) 133 | self.assertEqual(self.sensor_service._subscribers[0][1], mock_callback) 134 | 135 | async def test_deregister_for_updates(self): 136 | mock_callback = MagicMock() 137 | await self.sensor_service.register_for_updates(self.motion_sensor, mock_callback) 138 | await self.sensor_service.deregister_for_updates(self.motion_sensor) 139 | 140 | self.assertEqual(len(self.sensor_service._subscribers), 0) 141 | 142 | async def test_update_with_unknown_property(self): 143 | self.sensor_service._get_device_info.return_value = { 144 | 'data': { 145 | 'property_list': [ 146 | { 147 | 'pid': 'unknown_property', 148 | 'value': '1' 149 | } 150 | ] 151 | } 152 | } 153 | 154 | updated_sensor = await self.sensor_service.update(self.motion_sensor) 155 | self.assertFalse(updated_sensor.detected) # Should maintain default value 156 | 157 | 158 | if __name__ == '__main__': 159 | unittest.main() -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_switch_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime, timedelta 3 | from unittest.mock import AsyncMock, MagicMock 4 | from wyzeapy.services.switch_service import SwitchService, SwitchUsageService, Switch 5 | from wyzeapy.types import DeviceTypes, PropertyIDs 6 | from wyzeapy.wyze_auth_lib import WyzeAuthLib 7 | 8 | 9 | class TestSwitchService(unittest.IsolatedAsyncioTestCase): 10 | async def asyncSetUp(self): 11 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 12 | self.switch_service = SwitchService(auth_lib=self.mock_auth_lib) 13 | self.switch_service._get_property_list = AsyncMock() 14 | self.switch_service.get_updated_params = AsyncMock() 15 | self.switch_service.get_object_list = AsyncMock() 16 | self.switch_service._set_property = AsyncMock() 17 | 18 | # Create test switch 19 | self.test_switch = Switch({ 20 | "device_type": DeviceTypes.PLUG.value, 21 | "product_model": "WLPP1", 22 | "mac": "SWITCH123", 23 | "nickname": "Test Switch", 24 | "device_params": {"ip": "192.168.1.100"}, 25 | "raw_dict": {} 26 | }) 27 | 28 | async def test_update_switch_on(self): 29 | self.switch_service._get_property_list.return_value = [ 30 | (PropertyIDs.ON, "1"), 31 | (PropertyIDs.AVAILABLE, "1") 32 | ] 33 | 34 | updated_switch = await self.switch_service.update(self.test_switch) 35 | 36 | self.assertTrue(updated_switch.on) 37 | self.assertTrue(updated_switch.available) 38 | 39 | async def test_update_switch_off(self): 40 | self.switch_service._get_property_list.return_value = [ 41 | (PropertyIDs.ON, "0"), 42 | (PropertyIDs.AVAILABLE, "1") 43 | ] 44 | 45 | updated_switch = await self.switch_service.update(self.test_switch) 46 | 47 | self.assertFalse(updated_switch.on) 48 | self.assertTrue(updated_switch.available) 49 | 50 | async def test_get_switches(self): 51 | mock_plug = MagicMock() 52 | mock_plug.type = DeviceTypes.PLUG 53 | mock_plug.raw_dict = { 54 | "device_type": DeviceTypes.PLUG.value, 55 | "product_model": "WLPP1", 56 | "mac": "PLUG123" 57 | } 58 | 59 | mock_outdoor_plug = MagicMock() 60 | mock_outdoor_plug.type = DeviceTypes.OUTDOOR_PLUG 61 | mock_outdoor_plug.raw_dict = { 62 | "device_type": DeviceTypes.OUTDOOR_PLUG.value, 63 | "product_model": "WLPPO", 64 | "mac": "OUTPLUG456" 65 | } 66 | 67 | self.switch_service.get_object_list.return_value = [ 68 | mock_plug, 69 | mock_outdoor_plug 70 | ] 71 | 72 | switches = await self.switch_service.get_switches() 73 | 74 | self.assertEqual(len(switches), 2) 75 | self.assertIsInstance(switches[0], Switch) 76 | self.assertIsInstance(switches[1], Switch) 77 | self.switch_service.get_object_list.assert_awaited_once() 78 | 79 | async def test_turn_on(self): 80 | await self.switch_service.turn_on(self.test_switch) 81 | self.switch_service._set_property.assert_awaited_with( 82 | self.test_switch, 83 | PropertyIDs.ON.value, 84 | "1" 85 | ) 86 | 87 | async def test_turn_off(self): 88 | await self.switch_service.turn_off(self.test_switch) 89 | self.switch_service._set_property.assert_awaited_with( 90 | self.test_switch, 91 | PropertyIDs.ON.value, 92 | "0" 93 | ) 94 | 95 | 96 | class TestSwitchUsageService(unittest.IsolatedAsyncioTestCase): 97 | async def asyncSetUp(self): 98 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 99 | self.usage_service = SwitchUsageService(auth_lib=self.mock_auth_lib) 100 | self.usage_service._get_plug_history = AsyncMock() 101 | 102 | # Create test switch 103 | self.test_switch = Switch({ 104 | "device_type": DeviceTypes.PLUG.value, 105 | "product_model": "WLPP1", 106 | "mac": "SWITCH123", 107 | "nickname": "Test Switch", 108 | "device_params": {"ip": "192.168.1.100"}, 109 | "raw_dict": {} 110 | }) 111 | 112 | async def test_update_usage_history(self): 113 | mock_usage_data = { 114 | "total_power": 100, 115 | "time_series": [ 116 | {"power": 10, "timestamp": 1234567890}, 117 | {"power": 20, "timestamp": 1234567891} 118 | ] 119 | } 120 | self.usage_service._get_plug_history.return_value = mock_usage_data 121 | 122 | # Calculate expected timestamps 123 | now = datetime.now() 124 | expected_end_time = int(datetime.timestamp(now) * 1000) 125 | expected_start_time = int(datetime.timestamp(now - timedelta(hours=25)) * 1000) 126 | 127 | updated_switch = await self.usage_service.update(self.test_switch) 128 | 129 | self.assertEqual(updated_switch.usage_history, mock_usage_data) 130 | self.usage_service._get_plug_history.assert_awaited_with( 131 | self.test_switch, 132 | expected_start_time, 133 | expected_end_time 134 | ) 135 | 136 | 137 | if __name__ == '__main__': 138 | unittest.main() -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_thermostat_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.thermostat_service import ( 4 | ThermostatService, Thermostat, HVACMode, FanMode, 5 | TemperatureUnit, Preset, HVACState, ThermostatProps 6 | ) 7 | from wyzeapy.types import DeviceTypes 8 | from wyzeapy.wyze_auth_lib import WyzeAuthLib 9 | 10 | 11 | class TestThermostatService(unittest.IsolatedAsyncioTestCase): 12 | async def asyncSetUp(self): 13 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 14 | self.thermostat_service = ThermostatService(auth_lib=self.mock_auth_lib) 15 | self.thermostat_service._thermostat_get_iot_prop = AsyncMock() 16 | self.thermostat_service._thermostat_set_iot_prop = AsyncMock() 17 | self.thermostat_service.get_object_list = AsyncMock() 18 | 19 | # Create test thermostat 20 | self.test_thermostat = Thermostat({ 21 | "device_type": DeviceTypes.THERMOSTAT.value, 22 | "product_model": "WLPTH1", 23 | "mac": "THERM123", 24 | "nickname": "Test Thermostat", 25 | "device_params": {"ip": "192.168.1.100"}, 26 | "raw_dict": {} 27 | }) 28 | 29 | async def test_update_thermostat(self): 30 | self.thermostat_service._thermostat_get_iot_prop.return_value = { 31 | 'data': { 32 | 'props': { 33 | 'temp_unit': 'F', 34 | 'cool_sp': '74', 35 | 'heat_sp': '64', 36 | 'fan_mode': 'auto', 37 | 'mode_sys': 'auto', 38 | 'current_scenario': 'home', 39 | 'temperature': '71.5', 40 | 'iot_state': 'connected', 41 | 'humidity': '50', 42 | 'working_state': 'idle' 43 | } 44 | } 45 | } 46 | 47 | updated_thermostat = await self.thermostat_service.update(self.test_thermostat) 48 | 49 | self.assertEqual(updated_thermostat.temp_unit, TemperatureUnit.FAHRENHEIT) 50 | self.assertEqual(updated_thermostat.cool_set_point, 74) 51 | self.assertEqual(updated_thermostat.heat_set_point, 64) 52 | self.assertEqual(updated_thermostat.fan_mode, FanMode.AUTO) 53 | self.assertEqual(updated_thermostat.hvac_mode, HVACMode.AUTO) 54 | self.assertEqual(updated_thermostat.preset, Preset.HOME) 55 | self.assertEqual(updated_thermostat.temperature, 71.5) 56 | self.assertTrue(updated_thermostat.available) 57 | self.assertEqual(updated_thermostat.humidity, 50) 58 | self.assertEqual(updated_thermostat.hvac_state, HVACState.IDLE) 59 | 60 | async def test_get_thermostats(self): 61 | mock_thermostat = MagicMock() 62 | mock_thermostat.type = DeviceTypes.THERMOSTAT 63 | mock_thermostat.raw_dict = { 64 | "device_type": DeviceTypes.THERMOSTAT.value, 65 | "product_model": "WLPTH1", 66 | "mac": "THERM123" 67 | } 68 | 69 | self.thermostat_service.get_object_list.return_value = [mock_thermostat] 70 | 71 | thermostats = await self.thermostat_service.get_thermostats() 72 | 73 | self.assertEqual(len(thermostats), 1) 74 | self.assertIsInstance(thermostats[0], Thermostat) 75 | self.thermostat_service.get_object_list.assert_awaited_once() 76 | 77 | async def test_set_cool_point(self): 78 | await self.thermostat_service.set_cool_point(self.test_thermostat, 75) 79 | self.thermostat_service._thermostat_set_iot_prop.assert_awaited_with( 80 | self.test_thermostat, 81 | ThermostatProps.COOL_SP, 82 | 75 83 | ) 84 | 85 | async def test_set_heat_point(self): 86 | await self.thermostat_service.set_heat_point(self.test_thermostat, 68) 87 | self.thermostat_service._thermostat_set_iot_prop.assert_awaited_with( 88 | self.test_thermostat, 89 | ThermostatProps.HEAT_SP, 90 | 68 91 | ) 92 | 93 | async def test_set_hvac_mode(self): 94 | await self.thermostat_service.set_hvac_mode(self.test_thermostat, HVACMode.COOL) 95 | self.thermostat_service._thermostat_set_iot_prop.assert_awaited_with( 96 | self.test_thermostat, 97 | ThermostatProps.MODE_SYS, 98 | HVACMode.COOL.value 99 | ) 100 | 101 | async def test_set_fan_mode(self): 102 | await self.thermostat_service.set_fan_mode(self.test_thermostat, FanMode.ON) 103 | self.thermostat_service._thermostat_set_iot_prop.assert_awaited_with( 104 | self.test_thermostat, 105 | ThermostatProps.FAN_MODE, 106 | FanMode.ON.value 107 | ) 108 | 109 | async def test_set_preset(self): 110 | await self.thermostat_service.set_preset(self.test_thermostat, Preset.AWAY) 111 | self.thermostat_service._thermostat_set_iot_prop.assert_awaited_with( 112 | self.test_thermostat, 113 | ThermostatProps.CURRENT_SCENARIO, 114 | Preset.AWAY.value 115 | ) 116 | 117 | async def test_update_with_invalid_property(self): 118 | self.thermostat_service._thermostat_get_iot_prop.return_value = { 119 | 'data': { 120 | 'props': { 121 | 'invalid_property': 'some_value', 122 | 'temperature': '71.5' 123 | } 124 | } 125 | } 126 | 127 | updated_thermostat = await self.thermostat_service.update(self.test_thermostat) 128 | self.assertEqual(updated_thermostat.temperature, 71.5) 129 | # Other properties should maintain their default values 130 | self.assertEqual(updated_thermostat.temp_unit, TemperatureUnit.FAHRENHEIT) 131 | self.assertEqual(updated_thermostat.cool_set_point, 74) 132 | self.assertEqual(updated_thermostat.heat_set_point, 64) 133 | 134 | 135 | if __name__ == '__main__': 136 | unittest.main() -------------------------------------------------------------------------------- /src/wyzeapy/tests/test_wall_switch_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock, MagicMock 3 | from wyzeapy.services.wall_switch_service import ( 4 | WallSwitchService, WallSwitch, SinglePressType, WallSwitchProps 5 | ) 6 | from wyzeapy.types import DeviceTypes 7 | from wyzeapy.wyze_auth_lib import WyzeAuthLib 8 | 9 | 10 | class TestWallSwitchService(unittest.IsolatedAsyncioTestCase): 11 | async def asyncSetUp(self): 12 | self.mock_auth_lib = MagicMock(spec=WyzeAuthLib) 13 | self.wall_switch_service = WallSwitchService(auth_lib=self.mock_auth_lib) 14 | self.wall_switch_service._wall_switch_get_iot_prop = AsyncMock() 15 | self.wall_switch_service._wall_switch_set_iot_prop = AsyncMock() 16 | self.wall_switch_service.get_object_list = AsyncMock() 17 | 18 | # Create test wall switch 19 | self.test_switch = WallSwitch({ 20 | "device_type": DeviceTypes.COMMON.value, 21 | "product_model": "LD_SS1", 22 | "mac": "SWITCH123", 23 | "nickname": "Test Wall Switch", 24 | "device_params": {"ip": "192.168.1.100"}, 25 | "raw_dict": {} 26 | }) 27 | 28 | async def test_update_wall_switch(self): 29 | self.wall_switch_service._wall_switch_get_iot_prop.return_value = { 30 | 'data': { 31 | 'props': { 32 | 'iot_state': 'connected', 33 | 'switch-power': True, 34 | 'switch-iot': False, 35 | 'single_press_type': 1 36 | } 37 | } 38 | } 39 | 40 | updated_switch = await self.wall_switch_service.update(self.test_switch) 41 | 42 | self.assertTrue(updated_switch.available) 43 | self.assertTrue(updated_switch.switch_power) 44 | self.assertFalse(updated_switch.switch_iot) 45 | self.assertEqual(updated_switch.single_press_type, SinglePressType.CLASSIC) 46 | # Test the property that depends on single_press_type 47 | self.assertTrue(updated_switch.on) # Should be True because switch_power is True and type is CLASSIC 48 | 49 | async def test_update_wall_switch_iot_mode(self): 50 | self.wall_switch_service._wall_switch_get_iot_prop.return_value = { 51 | 'data': { 52 | 'props': { 53 | 'iot_state': 'connected', 54 | 'switch-power': False, 55 | 'switch-iot': True, 56 | 'single_press_type': 2 57 | } 58 | } 59 | } 60 | 61 | updated_switch = await self.wall_switch_service.update(self.test_switch) 62 | 63 | self.assertTrue(updated_switch.available) 64 | self.assertFalse(updated_switch.switch_power) 65 | self.assertTrue(updated_switch.switch_iot) 66 | self.assertEqual(updated_switch.single_press_type, SinglePressType.IOT) 67 | # Test the property that depends on single_press_type 68 | self.assertTrue(updated_switch.on) # Should be True because switch_iot is True and type is IOT 69 | 70 | async def test_get_switches(self): 71 | mock_switch = MagicMock() 72 | mock_switch.type = DeviceTypes.COMMON 73 | mock_switch.product_model = "LD_SS1" 74 | mock_switch.raw_dict = { 75 | "device_type": DeviceTypes.COMMON.value, 76 | "product_model": "LD_SS1", 77 | "mac": "SWITCH123" 78 | } 79 | 80 | # Add a non-wall switch device to test filtering 81 | mock_other_device = MagicMock() 82 | mock_other_device.type = DeviceTypes.COMMON 83 | mock_other_device.product_model = "OTHER_MODEL" 84 | 85 | self.wall_switch_service.get_object_list.return_value = [ 86 | mock_switch, 87 | mock_other_device 88 | ] 89 | 90 | switches = await self.wall_switch_service.get_switches() 91 | 92 | self.assertEqual(len(switches), 1) 93 | self.assertIsInstance(switches[0], WallSwitch) 94 | self.wall_switch_service.get_object_list.assert_awaited_once() 95 | 96 | async def test_turn_on_classic_mode(self): 97 | self.test_switch.single_press_type = SinglePressType.CLASSIC 98 | await self.wall_switch_service.turn_on(self.test_switch) 99 | self.wall_switch_service._wall_switch_set_iot_prop.assert_awaited_with( 100 | self.test_switch, 101 | WallSwitchProps.SWITCH_POWER, 102 | True 103 | ) 104 | 105 | async def test_turn_off_classic_mode(self): 106 | self.test_switch.single_press_type = SinglePressType.CLASSIC 107 | await self.wall_switch_service.turn_off(self.test_switch) 108 | self.wall_switch_service._wall_switch_set_iot_prop.assert_awaited_with( 109 | self.test_switch, 110 | WallSwitchProps.SWITCH_POWER, 111 | False 112 | ) 113 | 114 | async def test_turn_on_iot_mode(self): 115 | self.test_switch.single_press_type = SinglePressType.IOT 116 | await self.wall_switch_service.turn_on(self.test_switch) 117 | self.wall_switch_service._wall_switch_set_iot_prop.assert_awaited_with( 118 | self.test_switch, 119 | WallSwitchProps.SWITCH_IOT, 120 | True 121 | ) 122 | 123 | async def test_turn_off_iot_mode(self): 124 | self.test_switch.single_press_type = SinglePressType.IOT 125 | await self.wall_switch_service.turn_off(self.test_switch) 126 | self.wall_switch_service._wall_switch_set_iot_prop.assert_awaited_with( 127 | self.test_switch, 128 | WallSwitchProps.SWITCH_IOT, 129 | False 130 | ) 131 | 132 | async def test_set_single_press_type(self): 133 | await self.wall_switch_service.set_single_press_type( 134 | self.test_switch, 135 | SinglePressType.IOT 136 | ) 137 | self.wall_switch_service._wall_switch_set_iot_prop.assert_awaited_with( 138 | self.test_switch, 139 | WallSwitchProps.SINGLE_PRESS_TYPE, 140 | SinglePressType.IOT.value 141 | ) 142 | 143 | async def test_update_with_invalid_property(self): 144 | self.wall_switch_service._wall_switch_get_iot_prop.return_value = { 145 | 'data': { 146 | 'props': { 147 | 'invalid_property': 'some_value', 148 | 'switch-power': True 149 | } 150 | } 151 | } 152 | 153 | updated_switch = await self.wall_switch_service.update(self.test_switch) 154 | self.assertTrue(updated_switch.switch_power) 155 | # Other properties should maintain their default values 156 | self.assertEqual(updated_switch.single_press_type, SinglePressType.CLASSIC) 157 | self.assertFalse(updated_switch.switch_iot) 158 | 159 | 160 | if __name__ == '__main__': 161 | unittest.main() -------------------------------------------------------------------------------- /src/wyzeapy/types.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | from enum import Enum 7 | from typing import Union, List, Dict, Any 8 | 9 | 10 | class Group: 11 | group_id: str 12 | group_name: str 13 | 14 | def __init__(self, dictionary: Dict[Any, Any]): 15 | for k, v in dictionary.items(): 16 | setattr(self, k, v) 17 | 18 | def __repr__(self) -> str: 19 | return "".format(self.group_id, self.group_name) 20 | 21 | 22 | class DeviceTypes(Enum): 23 | LIGHT = "Light" 24 | PLUG = "Plug" 25 | OUTDOOR_PLUG = "OutdoorPlug" 26 | MESH_LIGHT = "MeshLight" 27 | CAMERA = "Camera" 28 | CHIME_SENSOR = "ChimeSensor" 29 | CONTACT_SENSOR = "ContactSensor" 30 | MOTION_SENSOR = "MotionSensor" 31 | LEAK_SENSOR = "LeakSensor" 32 | WRIST = "Wrist" 33 | BASE_STATION = "BaseStation" 34 | SCALE = "WyzeScale" 35 | LOCK = "Lock" 36 | GATEWAY = "gateway" 37 | COMMON = "Common" 38 | VACUUM = "JA_RO2" 39 | HEADPHONES = "JA.SC" 40 | THERMOSTAT = "Thermostat" 41 | GATEWAY_V2 = "GateWay" 42 | UNKNOWN = "Unknown" 43 | SENSE_V2_GATEWAY = "S1Gateway" 44 | KEYPAD = "Keypad" 45 | LIGHTSTRIP = "LightStrip" 46 | 47 | 48 | class Device: 49 | product_type: str 50 | product_model: str 51 | mac: str 52 | nickname: str 53 | device_params: Dict[str, Any] 54 | raw_dict: Dict[str, Any] 55 | callback_function = None 56 | 57 | def __init__(self, dictionary: Dict[Any, Any]): 58 | self.available = False 59 | 60 | self.raw_dict = dictionary 61 | for k, v in dictionary.items(): 62 | setattr(self, k, v) 63 | 64 | @property 65 | def type(self) -> DeviceTypes: 66 | try: 67 | return DeviceTypes(self.product_type) 68 | except ValueError: 69 | return DeviceTypes.UNKNOWN 70 | 71 | def __repr__(self) -> str: 72 | return "".format(DeviceTypes(self.product_type), self.mac) 73 | 74 | 75 | class Sensor(Device): 76 | def __init__(self, dictionary: Dict[Any, Any]): 77 | super().__init__(dictionary) 78 | 79 | @property 80 | def activity_detected(self) -> int: 81 | if self.type is DeviceTypes.CONTACT_SENSOR: 82 | return int(self.device_params['open_close_state']) 83 | elif self.type is DeviceTypes.MOTION_SENSOR: 84 | return int(self.device_params['motion_state']) 85 | else: 86 | raise AssertionError("Device must be of type CONTACT_SENSOR or MOTION_SENSOR") 87 | 88 | @property 89 | def is_low_battery(self) -> int: 90 | return int(self.device_params['is_low_battery']) 91 | 92 | 93 | class PropertyIDs(Enum): 94 | NOTIFICATION = "P1" 95 | ON = "P3" 96 | AVAILABLE = "P5" 97 | BRIGHTNESS = "P1501" # From 0-100 98 | COLOR_TEMP = "P1502" # In Kelvin 99 | COLOR = "P1507" # As a hex string RrGgBb 100 | COLOR_MODE = "P1508" # 1 = Basic Color, 2 = White, 3 = Effect Mode 101 | LIGHTSTRIP_EFFECTS = "P1522" 102 | LIGHTSTRIP_MUSIC_MODE = "P1535" 103 | DOOR_OPEN = "P2001" # 0 if the door is closed 104 | CONTACT_STATE = "P1301" 105 | MOTION_STATE = "P1302" 106 | CAMERA_SIREN = "P1049" 107 | ACCESSORY = "P1056" # Is state for camera accessories, like garage doors, light sockets, and floodlights. 108 | SUN_MATCH = "P1528" 109 | MOTION_DETECTION = "P1047" # Current Motion Detection State of the Camera 110 | MOTION_DETECTION_TOGGLE = "P1001" # This toggles Camera Motion Detection On/Off 111 | WCO_MOTION_DETECTION = "P1029" # Wyze cam outdoor requires both P1047 and P1029 to be set. P1029 is set via set_property_list 112 | 113 | 114 | class WallSwitchProps(Enum): 115 | IOT_STATE = "iot_state" # Connection state: connected, disconnected 116 | SWITCH_POWER = "switch-power" 117 | SWITCH_IOT = "switch-iot" 118 | SINGLE_PRESS_TYPE = "single_press_type" 119 | 120 | 121 | class ThermostatProps(Enum): 122 | APP_VERSION = "app_version" 123 | IOT_STATE = "iot_state" # Connection state: connected, disconnected 124 | SETUP_STATE = "setup_state" 125 | CURRENT_SCENARIO = "current_scenario" # home, away 126 | PROTECT_TIME = "protect_time" 127 | COOL_SP = "cool_sp" # Cool stop point 128 | EMHEAT = "emheat" 129 | TIME2TEMP_VAL = "time2temp_val" 130 | SAVE_COMFORT_BALANCE = "save_comfort_balance" # savings, comfort, or balance value 131 | QUERY_SCHEDULE = "query_schedule" 132 | WORKING_STATE = "working_state" # idle, etc. 133 | WIRING_LOGIC_ID = "wiring_logic_id" 134 | W_CITY_ID = "w_city_id" 135 | FAN_MODE = "fan_mode" # auto, on, off 136 | TEMPERATURE = "temperature" # current temp 137 | HUMIDITY = "humidity" # current humidity 138 | KID_LOCK = "kid_lock" 139 | CALIBRATE_HUMIDITY = "calibrate_humidity" 140 | HEAT_SP = "heat_sp" # heat stop point 141 | CALIBRATE_TEMPERATURE = "calibrate_temperature" 142 | MODE_SYS = "mode_sys" # auto, heat, cool 143 | W_LAT = "w_lat" 144 | CONFIG_SCENARIO = "config_scenario" 145 | FANCIRC_TIME = "fancirc_time" 146 | W_LON = "w_lon" 147 | DEV_HOLD = "dev_hold" 148 | TEMP_UNIT = "temp_unit" 149 | ASW_HOLD = "asw_hold" 150 | 151 | 152 | class ResponseCodes(Enum): 153 | SUCCESS = "1" 154 | PARAMETER_ERROR = "1001" 155 | ACCESS_TOKEN_ERROR = "2001" 156 | DEVICE_OFFLINE = '3019' 157 | 158 | 159 | class ResponseCodesLock(Enum): 160 | SUCCESS = 0 161 | 162 | 163 | class File: 164 | file_id: str 165 | type: Union[int, str] 166 | url: str 167 | status: int 168 | en_algorithm: int 169 | en_password: str 170 | is_ai: int 171 | ai_tag_list: List[Any] 172 | ai_url: str 173 | file_params: Dict[Any, Any] 174 | 175 | def __init__(self, dictionary: Dict[Any, Any]): 176 | for k, v in dictionary.items(): 177 | setattr(self, k, v) 178 | 179 | if self.type == 1: 180 | self.type = "Image" 181 | else: 182 | self.type = "Video" 183 | 184 | 185 | class Event: 186 | event_id: str 187 | device_mac: str 188 | device_model: str 189 | event_category: int 190 | event_value: str 191 | event_ts: int 192 | event_ack_result: int 193 | is_feedback_correct: int 194 | is_feedback_face: int 195 | is_feedback_person: int 196 | file_list: List[Dict[Any, Any]] 197 | event_params: Dict[Any, Any] 198 | recognized_instance_list: List[Any] 199 | tag_list: List[Any] 200 | read_state: int 201 | 202 | def __init__(self, dictionary: Dict[Any, Any]): 203 | for k, v in dictionary.items(): 204 | setattr(self, k, v) 205 | 206 | 207 | class HMSStatus(Enum): 208 | DISARMED = 'disarmed' 209 | HOME = 'home' 210 | AWAY = 'away' 211 | 212 | 213 | class DeviceMgmtToggleType: 214 | def __init__(self, pageId, toggleId): 215 | self.pageId = pageId 216 | self.toggleId = toggleId 217 | 218 | 219 | class DeviceMgmtToggleProps(Enum): 220 | EVENT_RECORDING_TOGGLE = DeviceMgmtToggleType("cam_event_recording", "ge.motion_detect_recording") 221 | NOTIFICATION_TOGGLE = DeviceMgmtToggleType("cam_device_notify", "ge.push_switch") 222 | 223 | -------------------------------------------------------------------------------- /src/wyzeapy/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import base64 7 | import binascii 8 | import hashlib 9 | from typing import Dict, Any, List, Optional 10 | 11 | from Crypto.Cipher import AES 12 | 13 | from .exceptions import ParameterError, AccessTokenError, UnknownApiError 14 | from .types import ResponseCodes, PropertyIDs, Device, Event 15 | 16 | PADDING = bytes.fromhex("05") 17 | 18 | 19 | def pad(plain_text): 20 | """ 21 | func to pad cleartext to be multiples of 8-byte blocks. 22 | If you want to encrypt a text message that is not multiples of 8-byte 23 | blocks, the text message must be padded with additional bytes to make the 24 | text message to be multiples of 8-byte blocks. 25 | """ 26 | raw = plain_text.encode("ascii") 27 | 28 | pad_num = AES.block_size - len(raw) % AES.block_size 29 | raw += PADDING * pad_num 30 | 31 | return raw 32 | 33 | 34 | def wyze_encrypt(key, text): 35 | """ 36 | Reimplementation of the Wyze app's encryption mechanism. 37 | 38 | The decompiled code can be found here 👇 39 | https://paste.sr.ht/~joshmulliken/e9f67e05c4a774004b226d2ac1f070b6d341cb39 40 | """ 41 | raw = pad(text) 42 | key = key.encode("ascii") 43 | iv = key # Wyze uses the secret key for the iv as well 44 | cipher = AES.new(key, AES.MODE_CBC, iv) 45 | enc = cipher.encrypt(raw) 46 | b64_enc = base64.b64encode(enc).decode("ascii") 47 | b64_enc = b64_enc.replace("/", r'\/') 48 | return b64_enc 49 | 50 | 51 | def wyze_decrypt(key, enc): 52 | """ 53 | Reimplementation of the Wyze app's decryption mechanism. 54 | 55 | The decompiled code can be found here 👇 56 | https://paste.sr.ht/~joshmulliken/e9f67e05c4a774004b226d2ac1f070b6d341cb39 57 | """ 58 | enc = base64.b64decode(enc) 59 | 60 | key = key.encode('ascii') 61 | iv = key 62 | cipher = AES.new(key, AES.MODE_CBC, iv) 63 | decrypt = cipher.decrypt(enc) 64 | 65 | decrypt_txt = decrypt.decode("ascii") 66 | 67 | return decrypt_txt 68 | 69 | 70 | def wyze_decrypt_cbc(key: str, enc_hex_str: str) -> str: 71 | key_hash = hashlib.md5(key.encode("utf-8")).digest() 72 | 73 | iv = b"0123456789ABCDEF" 74 | cipher = AES.new(key_hash, AES.MODE_CBC, iv) 75 | 76 | encrypted_bytes = binascii.unhexlify(enc_hex_str) 77 | decrypted_bytes = cipher.decrypt(encrypted_bytes) 78 | 79 | # PKCS5Padding 80 | padding_length = decrypted_bytes[-1] 81 | return decrypted_bytes[:-padding_length].decode() 82 | 83 | 84 | def create_password(password: str) -> str: 85 | hex1 = hashlib.md5(password.encode()).hexdigest() 86 | hex2 = hashlib.md5(hex1.encode()).hexdigest() 87 | return hashlib.md5(hex2.encode()).hexdigest() 88 | 89 | 90 | def check_for_errors_standard(service, response_json: Dict[str, Any]) -> None: 91 | response_code = response_json['code'] 92 | if response_code != ResponseCodes.SUCCESS.value: 93 | if response_code == ResponseCodes.PARAMETER_ERROR.value: 94 | raise ParameterError(response_code, response_json['msg']) 95 | elif response_code == ResponseCodes.ACCESS_TOKEN_ERROR.value: 96 | service._auth_lib.token.expired = True 97 | raise AccessTokenError(response_code, "Access Token expired, attempting to refresh") 98 | elif response_code == ResponseCodes.DEVICE_OFFLINE.value: 99 | return 100 | else: 101 | raise UnknownApiError(response_code, response_json['msg']) 102 | 103 | 104 | def check_for_errors_lock(service, response_json: Dict[str, Any]) -> None: 105 | if response_json['ErrNo'] != 0: 106 | if response_json.get('code') == ResponseCodes.PARAMETER_ERROR.value: 107 | raise ParameterError(response_json) 108 | elif response_json.get('code') == ResponseCodes.ACCESS_TOKEN_ERROR.value: 109 | service._auth_lib.token.expired = True 110 | raise AccessTokenError("Access Token expired, attempting to refresh") 111 | else: 112 | raise UnknownApiError(response_json) 113 | 114 | 115 | def check_for_errors_devicemgmt(service, response_json: Dict[Any, Any]) -> None: 116 | if response_json['status'] != 200: 117 | if "InvalidTokenError>" in response_json['response']['errors'][0]['message']: 118 | service._auth_lib.token.expired = True 119 | raise AccessTokenError("Access Token expired, attempting to refresh") 120 | else: 121 | raise UnknownApiError(response_json) 122 | 123 | 124 | def check_for_errors_iot(service, response_json: Dict[Any, Any]) -> None: 125 | if response_json['code'] != 1: 126 | if str(response_json['code']) == ResponseCodes.ACCESS_TOKEN_ERROR.value: 127 | service._auth_lib.token.expired = True 128 | raise AccessTokenError("Access Token expired, attempting to refresh") 129 | else: 130 | raise UnknownApiError(response_json) 131 | 132 | def check_for_errors_hms(service, response_json: Dict[Any, Any]) -> None: 133 | if response_json['message'] is None: 134 | service._auth_lib.token.expired = True 135 | raise AccessTokenError("Access Token expired, attempting to refresh") 136 | 137 | 138 | def return_event_for_device(device: Device, events: List[Event]) -> Optional[Event]: 139 | for event in events: 140 | if event.device_mac == device.mac: 141 | return event 142 | 143 | return None 144 | 145 | 146 | def create_pid_pair(pid_enum: PropertyIDs, value: str) -> Dict[str, str]: 147 | return {"pid": pid_enum.value, "pvalue": value} 148 | -------------------------------------------------------------------------------- /src/wyzeapy/wyze_auth_lib.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Mulliken, LLC - All Rights Reserved 2 | # You may use, distribute and modify this code under the terms 3 | # of the attached license. You should have received a copy of 4 | # the license with this file. If not, please write to: 5 | # katie@mulliken.net to receive a copy 6 | import asyncio 7 | import logging 8 | import time 9 | from typing import Dict, Any, Optional 10 | 11 | from aiohttp import TCPConnector, ClientSession, ContentTypeError 12 | 13 | from .const import API_KEY, PHONE_ID, APP_NAME, APP_VERSION, SC, SV, PHONE_SYSTEM_TYPE, APP_VER, APP_INFO 14 | from .exceptions import ( 15 | UnknownApiError, 16 | TwoFactorAuthenticationEnabled, 17 | AccessTokenError, 18 | ) 19 | from .utils import create_password, check_for_errors_standard 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class Token: 25 | # Token is apparently good for 24 hours, so refresh after 23 26 | REFRESH_INTERVAL = 82800 27 | 28 | def __init__(self, access_token, refresh_token, refresh_time: float = None): 29 | self._access_token: str = access_token 30 | self._refresh_token: str = refresh_token 31 | self.expired = False 32 | if refresh_time: 33 | self._refresh_time: float = refresh_time 34 | else: 35 | self._refresh_time: float = time.time() + Token.REFRESH_INTERVAL 36 | 37 | @property 38 | def access_token(self): 39 | return self._access_token 40 | 41 | @access_token.setter 42 | def access_token(self, access_token): 43 | self._access_token = access_token 44 | self._refresh_time = time.time() + Token.REFRESH_INTERVAL 45 | 46 | @property 47 | def refresh_token(self): 48 | return self._refresh_token 49 | 50 | @refresh_token.setter 51 | def refresh_token(self, refresh_token): 52 | self._refresh_token = refresh_token 53 | 54 | @property 55 | def refresh_time(self): 56 | return self._refresh_time 57 | 58 | 59 | class WyzeAuthLib: 60 | token: Optional[Token] = None 61 | SANITIZE_FIELDS = [ 62 | "email", 63 | "password", 64 | "access_token", 65 | "accessToken", 66 | "refresh_token", 67 | "lat", 68 | "lon", 69 | "address", 70 | ] 71 | SANITIZE_STRING = "**Sanitized**" 72 | 73 | def __init__( 74 | self, 75 | username=None, 76 | password=None, 77 | key_id=None, 78 | api_key=None, 79 | token: Optional[Token] = None, 80 | token_callback=None, 81 | ): 82 | self._username = username 83 | self._password = password 84 | self._key_id = key_id 85 | self._api_key = api_key 86 | self.token = token 87 | self.session_id = "" 88 | self.verification_id = "" 89 | self.two_factor_type = None 90 | self.refresh_lock = asyncio.Lock() 91 | self.token_callback = token_callback 92 | 93 | @classmethod 94 | async def create( 95 | cls, 96 | username=None, 97 | password=None, 98 | key_id=None, 99 | api_key=None, 100 | token: Optional[Token] = None, 101 | token_callback=None, 102 | ): 103 | self = cls( 104 | username=username, 105 | password=password, 106 | key_id=key_id, 107 | api_key=api_key, 108 | token=token, 109 | token_callback=token_callback, 110 | ) 111 | 112 | if self._username is None and self._password is None and self.token is None: 113 | raise AttributeError("Must provide a username, password or token") 114 | elif self.token is None and self._username is not None and self._password is not None: 115 | assert self._username != "" 116 | assert self._password != "" 117 | 118 | return self 119 | 120 | async def get_token_with_username_password( 121 | self, username, password, key_id, api_key 122 | ) -> Token: 123 | self._username = username 124 | self._password = create_password(password) 125 | self._key_id = key_id 126 | self._api_key = api_key 127 | login_payload = {"email": self._username, "password": self._password} 128 | 129 | headers = { 130 | "keyid": key_id, 131 | "apikey": api_key, 132 | "User-Agent": "wyzeapy", 133 | } 134 | 135 | response_json = await self.post( 136 | "https://auth-prod.api.wyze.com/api/user/login", 137 | headers=headers, 138 | json=login_payload, 139 | ) 140 | 141 | if response_json.get('errorCode') is not None: 142 | _LOGGER.error(f"Unable to login with response from Wyze: {response_json}") 143 | if response_json["errorCode"] == 1000: 144 | raise AccessTokenError 145 | raise UnknownApiError(response_json) 146 | 147 | if response_json.get('mfa_options') is not None: 148 | # Store the TOTP verification setting in the token and raise exception 149 | if "TotpVerificationCode" in response_json.get("mfa_options"): 150 | self.two_factor_type = "TOTP" 151 | # Store the verification_id from the response, it's needed for the 2fa payload. 152 | self.verification_id = response_json["mfa_details"]["totp_apps"][0]["app_id"] 153 | raise TwoFactorAuthenticationEnabled 154 | # 2fa using SMS, store sms as 2fa method in token, send the code then raise exception 155 | if "PrimaryPhone" in response_json.get("mfa_options"): 156 | self.two_factor_type = "SMS" 157 | params = { 158 | 'mfaPhoneType': 'Primary', 159 | 'sessionId': response_json.get("sms_session_id"), 160 | 'userId': response_json['user_id'], 161 | } 162 | response_json = await self.post('https://auth-prod.api.wyze.com/user/login/sendSmsCode', 163 | headers=headers, data=params) 164 | # Store the session_id from this response, it's needed for the 2fa payload. 165 | self.session_id = response_json['session_id'] 166 | raise TwoFactorAuthenticationEnabled 167 | 168 | self.token = Token(response_json['access_token'], response_json['refresh_token']) 169 | await self.token_callback(self.token) 170 | return self.token 171 | 172 | async def get_token_with_2fa(self, verification_code) -> Token: 173 | headers = { 174 | 'Phone-Id': PHONE_ID, 175 | 'User-Agent': APP_INFO, 176 | 'X-API-Key': API_KEY, 177 | } 178 | # TOTP Payload 179 | if self.two_factor_type == "TOTP": 180 | payload = { 181 | "email": self._username, 182 | "password": self._password, 183 | "mfa_type": "TotpVerificationCode", 184 | "verification_id": self.verification_id, 185 | "verification_code": verification_code 186 | } 187 | # SMS Payload 188 | else: 189 | payload = { 190 | "email": self._username, 191 | "password": self._password, 192 | "mfa_type": "PrimaryPhone", 193 | "verification_id": self.session_id, 194 | "verification_code": verification_code 195 | } 196 | 197 | response_json = await self.post( 198 | 'https://auth-prod.api.wyze.com/user/login', 199 | headers=headers, json=payload) 200 | 201 | self.token = Token(response_json['access_token'], response_json['refresh_token']) 202 | await self.token_callback(self.token) 203 | return self.token 204 | 205 | @property 206 | def should_refresh(self) -> bool: 207 | return time.time() >= self.token.refresh_time 208 | 209 | async def refresh_if_should(self): 210 | if self.should_refresh or self.token.expired: 211 | async with self.refresh_lock: 212 | if self.should_refresh or self.token.expired: 213 | _LOGGER.debug("Should refresh. Refreshing...") 214 | await self.refresh() 215 | 216 | async def refresh(self) -> None: 217 | payload = { 218 | "phone_id": PHONE_ID, 219 | "app_name": APP_NAME, 220 | "app_version": APP_VERSION, 221 | "sc": SC, 222 | "sv": SV, 223 | "phone_system_type": PHONE_SYSTEM_TYPE, 224 | "app_ver": APP_VER, 225 | "ts": int(time.time()), 226 | "refresh_token": self.token.refresh_token 227 | } 228 | 229 | headers = { 230 | "X-API-Key": API_KEY 231 | } 232 | 233 | async with ClientSession(connector=TCPConnector(ttl_dns_cache=(30 * 60))) as _session: 234 | response = await _session.post("https://api.wyzecam.com/app/user/refresh_token", headers=headers, 235 | json=payload) 236 | response_json = await response.json() 237 | check_for_errors_standard(self, response_json) 238 | 239 | self.token.access_token = response_json['data']['access_token'] 240 | self.token.refresh_token = response_json['data']['refresh_token'] 241 | await self.token_callback(self.token) 242 | self.token.expired = False 243 | 244 | def sanitize(self, data): 245 | if data and type(data) is dict: 246 | # value is unused, but it prevents us from having to split the tuple to check against SANITIZE_FIELDS 247 | for key, value in data.items(): 248 | if type(value) is dict: 249 | data[key] = self.sanitize(value) 250 | if key in self.SANITIZE_FIELDS: 251 | data[key] = self.SANITIZE_STRING 252 | return data 253 | 254 | async def post(self, url, json=None, headers=None, data=None) -> Dict[Any, Any]: 255 | async with ClientSession(connector=TCPConnector(ttl_dns_cache=(30 * 60))) as _session: 256 | response = await _session.post(url, json=json, headers=headers, data=data) 257 | # Relocated these below as the sanitization seems to modify the data before it goes to the post. 258 | _LOGGER.debug("Request:") 259 | _LOGGER.debug(f"url: {url}") 260 | _LOGGER.debug(f"json: {self.sanitize(json)}") 261 | _LOGGER.debug(f"headers: {self.sanitize(headers)}") 262 | _LOGGER.debug(f"data: {self.sanitize(data)}") 263 | # Log the response.json() if it exists, if not log the response. 264 | try: 265 | response_json = await response.json() 266 | _LOGGER.debug(f"Response Json: {self.sanitize(response_json)}") 267 | except ContentTypeError: 268 | _LOGGER.debug(f"Response: {response}") 269 | return await response.json() 270 | 271 | async def put(self, url, json=None, headers=None, data=None) -> Dict[Any, Any]: 272 | async with ClientSession(connector=TCPConnector(ttl_dns_cache=(30 * 60))) as _session: 273 | response = await _session.put(url, json=json, headers=headers, data=data) 274 | # Relocated these below as the sanitization seems to modify the data before it goes to the post. 275 | _LOGGER.debug("Request:") 276 | _LOGGER.debug(f"url: {url}") 277 | _LOGGER.debug(f"json: {self.sanitize(json)}") 278 | _LOGGER.debug(f"headers: {self.sanitize(headers)}") 279 | _LOGGER.debug(f"data: {self.sanitize(data)}") 280 | # Log the response.json() if it exists, if not log the response. 281 | try: 282 | response_json = await response.json() 283 | _LOGGER.debug(f"Response Json: {self.sanitize(response_json)}") 284 | except ContentTypeError: 285 | _LOGGER.debug(f"Response: {response}") 286 | return await response.json() 287 | 288 | async def get(self, url, headers=None, params=None) -> Dict[Any, Any]: 289 | async with ClientSession(connector=TCPConnector(ttl_dns_cache=(30 * 60))) as _session: 290 | response = await _session.get(url, params=params, headers=headers) 291 | # Relocated these below as the sanitization seems to modify the data before it goes to the post. 292 | _LOGGER.debug("Request:") 293 | _LOGGER.debug(f"url: {url}") 294 | _LOGGER.debug(f"headers: {self.sanitize(headers)}") 295 | _LOGGER.debug(f"params: {self.sanitize(params)}") 296 | # Log the response.json() if it exists, if not log the response. 297 | try: 298 | response_json = await response.json() 299 | _LOGGER.debug(f"Response Json: {self.sanitize(response_json)}") 300 | except ContentTypeError: 301 | _LOGGER.debug(f"Response: {response}") 302 | return await response.json() 303 | 304 | async def patch(self, url, headers=None, params=None, json=None) -> Dict[Any, Any]: 305 | async with ClientSession(connector=TCPConnector(ttl_dns_cache=(30 * 60))) as _session: 306 | response = await _session.patch(url, headers=headers, params=params, json=json) 307 | # Relocated these below as the sanitization seems to modify the data before it goes to the post. 308 | _LOGGER.debug("Request:") 309 | _LOGGER.debug(f"url: {url}") 310 | _LOGGER.debug(f"json: {self.sanitize(json)}") 311 | _LOGGER.debug(f"headers: {self.sanitize(headers)}") 312 | _LOGGER.debug(f"params: {self.sanitize(params)}") 313 | # Log the response.json() if it exists, if not log the response. 314 | try: 315 | response_json = await response.json() 316 | _LOGGER.debug(f"Response Json: {self.sanitize(response_json)}") 317 | except ContentTypeError: 318 | _LOGGER.debug(f"Response: {response}") 319 | return await response.json() 320 | 321 | async def delete(self, url, headers=None, json=None) -> Dict[Any, Any]: 322 | async with ClientSession(connector=TCPConnector(ttl_dns_cache=(30 * 60))) as _session: 323 | response = await _session.delete(url, headers=headers, json=json) 324 | # Relocated these below as the sanitization seems to modify the data before it goes to the post. 325 | _LOGGER.debug("Request:") 326 | _LOGGER.debug(f"url: {url}") 327 | _LOGGER.debug(f"json: {self.sanitize(json)}") 328 | _LOGGER.debug(f"headers: {self.sanitize(headers)}") 329 | # Log the response.json() if it exists, if not log the response. 330 | try: 331 | response_json = await response.json() 332 | _LOGGER.debug(f"Response Json: {self.sanitize(response_json)}") 333 | except ContentTypeError: 334 | _LOGGER.debug(f"Response: {response}") 335 | return await response.json() 336 | --------------------------------------------------------------------------------