├── .bandit.yaml ├── .codeclimate.yml ├── .coverage ├── .coveragerc ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── labeler.yml ├── pull_request_template.md ├── release-drafter.yml ├── stale.yml └── workflows │ ├── ci.yaml │ ├── publish.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .whitesource ├── AUTHORS.md ├── LICENSE ├── README.md ├── eufy_security_ws_python ├── __init__.py ├── client.py ├── const.py ├── errors.py ├── event.py ├── model │ ├── __init__.py │ ├── device.py │ ├── driver.py │ ├── station.py │ └── version.py └── version.py ├── examples ├── __init__.py └── test_websocket.py ├── poetry.lock ├── pylintrc ├── pyproject.toml ├── renovate.json ├── requirements_test.txt ├── script ├── release ├── setup └── test └── tests ├── __init__.py ├── common.py ├── conftest.py ├── fixtures ├── __init__.py ├── controller_state.json └── event.json ├── model └── __init__.py ├── test_client.py ├── test_event.py └── test_version.py /.bandit.yaml: -------------------------------------------------------------------------------- 1 | 2 | ### Bandit config file generated from: 3 | # '.venv/bin/bandit-config-generator -o .bandit.yaml' 4 | 5 | ### This config may optionally select a subset of tests to run or skip by 6 | ### filling out the 'tests' and 'skips' lists given below. If no tests are 7 | ### specified for inclusion then it is assumed all tests are desired. The skips 8 | ### set will remove specific tests from the include set. This can be controlled 9 | ### using the -t/-s CLI options. Note that the same test ID should not appear 10 | ### in both 'tests' and 'skips', this would be nonsensical and is detected by 11 | ### Bandit at runtime. 12 | 13 | # Available tests: 14 | # B101 : assert_used 15 | # B102 : exec_used 16 | # B103 : set_bad_file_permissions 17 | # B104 : hardcoded_bind_all_interfaces 18 | # B105 : hardcoded_password_string 19 | # B106 : hardcoded_password_funcarg 20 | # B107 : hardcoded_password_default 21 | # B108 : hardcoded_tmp_directory 22 | # B110 : try_except_pass 23 | # B112 : try_except_continue 24 | # B201 : flask_debug_true 25 | # B301 : pickle 26 | # B302 : marshal 27 | # B303 : md5 28 | # B304 : ciphers 29 | # B305 : cipher_modes 30 | # B306 : mktemp_q 31 | # B307 : eval 32 | # B308 : mark_safe 33 | # B309 : httpsconnection 34 | # B310 : urllib_urlopen 35 | # B311 : random 36 | # B312 : telnetlib 37 | # B313 : xml_bad_cElementTree 38 | # B314 : xml_bad_ElementTree 39 | # B315 : xml_bad_expatreader 40 | # B316 : xml_bad_expatbuilder 41 | # B317 : xml_bad_sax 42 | # B318 : xml_bad_minidom 43 | # B319 : xml_bad_pulldom 44 | # B320 : xml_bad_etree 45 | # B321 : ftplib 46 | # B322 : input 47 | # B323 : unverified_context 48 | # B324 : hashlib_new_insecure_functions 49 | # B325 : tempnam 50 | # B401 : import_telnetlib 51 | # B402 : import_ftplib 52 | # B403 : import_pickle 53 | # B404 : import_subprocess 54 | # B405 : import_xml_etree 55 | # B406 : import_xml_sax 56 | # B407 : import_xml_expat 57 | # B408 : import_xml_minidom 58 | # B409 : import_xml_pulldom 59 | # B410 : import_lxml 60 | # B411 : import_xmlrpclib 61 | # B412 : import_httpoxy 62 | # B413 : import_pycrypto 63 | # B501 : request_with_no_cert_validation 64 | # B502 : ssl_with_bad_version 65 | # B503 : ssl_with_bad_defaults 66 | # B504 : ssl_with_no_version 67 | # B505 : weak_cryptographic_key 68 | # B506 : yaml_load 69 | # B507 : ssh_no_host_key_verification 70 | # B601 : paramiko_calls 71 | # B602 : subprocess_popen_with_shell_equals_true 72 | # B603 : subprocess_without_shell_equals_true 73 | # B604 : any_other_function_with_shell_equals_true 74 | # B605 : start_process_with_a_shell 75 | # B606 : start_process_with_no_shell 76 | # B607 : start_process_with_partial_path 77 | # B608 : hardcoded_sql_expressions 78 | # B609 : linux_commands_wildcard_injection 79 | # B610 : django_extra_used 80 | # B611 : django_rawsql_used 81 | # B701 : jinja2_autoescape_false 82 | # B702 : use_of_mako_templates 83 | # B703 : django_mark_safe 84 | 85 | # (optional) list included test IDs here, eg '[B101, B406]': 86 | tests: 87 | 88 | # (optional) list skipped test IDs here, eg '[B101, B406]': 89 | skips: 90 | 91 | ### (optional) plugin settings - some test plugins require configuration data 92 | ### that may be given here, per-plugin. All bandit test plugins have a built in 93 | ### set of sensible defaults and these will be used if no configuration is 94 | ### provided. It is not necessary to provide settings for every (or any) plugin 95 | ### if the defaults are acceptable. 96 | 97 | any_other_function_with_shell_equals_true: 98 | no_shell: 99 | - os.execl 100 | - os.execle 101 | - os.execlp 102 | - os.execlpe 103 | - os.execv 104 | - os.execve 105 | - os.execvp 106 | - os.execvpe 107 | - os.spawnl 108 | - os.spawnle 109 | - os.spawnlp 110 | - os.spawnlpe 111 | - os.spawnv 112 | - os.spawnve 113 | - os.spawnvp 114 | - os.spawnvpe 115 | - os.startfile 116 | shell: 117 | - os.system 118 | - os.popen 119 | - os.popen2 120 | - os.popen3 121 | - os.popen4 122 | - popen2.popen2 123 | - popen2.popen3 124 | - popen2.popen4 125 | - popen2.Popen3 126 | - popen2.Popen4 127 | - commands.getoutput 128 | - commands.getstatusoutput 129 | subprocess: 130 | - subprocess.Popen 131 | - subprocess.call 132 | - subprocess.check_call 133 | - subprocess.check_output 134 | - subprocess.run 135 | hardcoded_tmp_directory: 136 | tmp_dirs: 137 | - /tmp 138 | - /var/tmp 139 | - /dev/shm 140 | linux_commands_wildcard_injection: 141 | no_shell: 142 | - os.execl 143 | - os.execle 144 | - os.execlp 145 | - os.execlpe 146 | - os.execv 147 | - os.execve 148 | - os.execvp 149 | - os.execvpe 150 | - os.spawnl 151 | - os.spawnle 152 | - os.spawnlp 153 | - os.spawnlpe 154 | - os.spawnv 155 | - os.spawnve 156 | - os.spawnvp 157 | - os.spawnvpe 158 | - os.startfile 159 | shell: 160 | - os.system 161 | - os.popen 162 | - os.popen2 163 | - os.popen3 164 | - os.popen4 165 | - popen2.popen2 166 | - popen2.popen3 167 | - popen2.popen4 168 | - popen2.Popen3 169 | - popen2.Popen4 170 | - commands.getoutput 171 | - commands.getstatusoutput 172 | subprocess: 173 | - subprocess.Popen 174 | - subprocess.call 175 | - subprocess.check_call 176 | - subprocess.check_output 177 | - subprocess.run 178 | ssl_with_bad_defaults: 179 | bad_protocol_versions: 180 | - PROTOCOL_SSLv2 181 | - SSLv2_METHOD 182 | - SSLv23_METHOD 183 | - PROTOCOL_SSLv3 184 | - PROTOCOL_TLSv1 185 | - SSLv3_METHOD 186 | - TLSv1_METHOD 187 | ssl_with_bad_version: 188 | bad_protocol_versions: 189 | - PROTOCOL_SSLv2 190 | - SSLv2_METHOD 191 | - SSLv23_METHOD 192 | - PROTOCOL_SSLv3 193 | - PROTOCOL_TLSv1 194 | - SSLv3_METHOD 195 | - TLSv1_METHOD 196 | start_process_with_a_shell: 197 | no_shell: 198 | - os.execl 199 | - os.execle 200 | - os.execlp 201 | - os.execlpe 202 | - os.execv 203 | - os.execve 204 | - os.execvp 205 | - os.execvpe 206 | - os.spawnl 207 | - os.spawnle 208 | - os.spawnlp 209 | - os.spawnlpe 210 | - os.spawnv 211 | - os.spawnve 212 | - os.spawnvp 213 | - os.spawnvpe 214 | - os.startfile 215 | shell: 216 | - os.system 217 | - os.popen 218 | - os.popen2 219 | - os.popen3 220 | - os.popen4 221 | - popen2.popen2 222 | - popen2.popen3 223 | - popen2.popen4 224 | - popen2.Popen3 225 | - popen2.Popen4 226 | - commands.getoutput 227 | - commands.getstatusoutput 228 | subprocess: 229 | - subprocess.Popen 230 | - subprocess.call 231 | - subprocess.check_call 232 | - subprocess.check_output 233 | - subprocess.run 234 | start_process_with_no_shell: 235 | no_shell: 236 | - os.execl 237 | - os.execle 238 | - os.execlp 239 | - os.execlpe 240 | - os.execv 241 | - os.execve 242 | - os.execvp 243 | - os.execvpe 244 | - os.spawnl 245 | - os.spawnle 246 | - os.spawnlp 247 | - os.spawnlpe 248 | - os.spawnv 249 | - os.spawnve 250 | - os.spawnvp 251 | - os.spawnvpe 252 | - os.startfile 253 | shell: 254 | - os.system 255 | - os.popen 256 | - os.popen2 257 | - os.popen3 258 | - os.popen4 259 | - popen2.popen2 260 | - popen2.popen3 261 | - popen2.popen4 262 | - popen2.Popen3 263 | - popen2.Popen4 264 | - commands.getoutput 265 | - commands.getstatusoutput 266 | subprocess: 267 | - subprocess.Popen 268 | - subprocess.call 269 | - subprocess.check_call 270 | - subprocess.check_output 271 | - subprocess.run 272 | start_process_with_partial_path: 273 | no_shell: 274 | - os.execl 275 | - os.execle 276 | - os.execlp 277 | - os.execlpe 278 | - os.execv 279 | - os.execve 280 | - os.execvp 281 | - os.execvpe 282 | - os.spawnl 283 | - os.spawnle 284 | - os.spawnlp 285 | - os.spawnlpe 286 | - os.spawnv 287 | - os.spawnve 288 | - os.spawnvp 289 | - os.spawnvpe 290 | - os.startfile 291 | shell: 292 | - os.system 293 | - os.popen 294 | - os.popen2 295 | - os.popen3 296 | - os.popen4 297 | - popen2.popen2 298 | - popen2.popen3 299 | - popen2.popen4 300 | - popen2.Popen3 301 | - popen2.Popen4 302 | - commands.getoutput 303 | - commands.getstatusoutput 304 | subprocess: 305 | - subprocess.Popen 306 | - subprocess.call 307 | - subprocess.check_call 308 | - subprocess.check_output 309 | - subprocess.run 310 | subprocess_popen_with_shell_equals_true: 311 | no_shell: 312 | - os.execl 313 | - os.execle 314 | - os.execlp 315 | - os.execlpe 316 | - os.execv 317 | - os.execve 318 | - os.execvp 319 | - os.execvpe 320 | - os.spawnl 321 | - os.spawnle 322 | - os.spawnlp 323 | - os.spawnlpe 324 | - os.spawnv 325 | - os.spawnve 326 | - os.spawnvp 327 | - os.spawnvpe 328 | - os.startfile 329 | shell: 330 | - os.system 331 | - os.popen 332 | - os.popen2 333 | - os.popen3 334 | - os.popen4 335 | - popen2.popen2 336 | - popen2.popen3 337 | - popen2.popen4 338 | - popen2.Popen3 339 | - popen2.Popen4 340 | - commands.getoutput 341 | - commands.getstatusoutput 342 | subprocess: 343 | - subprocess.Popen 344 | - subprocess.call 345 | - subprocess.check_call 346 | - subprocess.check_output 347 | - subprocess.run 348 | subprocess_without_shell_equals_true: 349 | no_shell: 350 | - os.execl 351 | - os.execle 352 | - os.execlp 353 | - os.execlpe 354 | - os.execv 355 | - os.execve 356 | - os.execvp 357 | - os.execvpe 358 | - os.spawnl 359 | - os.spawnle 360 | - os.spawnlp 361 | - os.spawnlpe 362 | - os.spawnv 363 | - os.spawnve 364 | - os.spawnvp 365 | - os.spawnvpe 366 | - os.startfile 367 | shell: 368 | - os.system 369 | - os.popen 370 | - os.popen2 371 | - os.popen3 372 | - os.popen4 373 | - popen2.popen2 374 | - popen2.popen3 375 | - popen2.popen4 376 | - popen2.Popen3 377 | - popen2.Popen4 378 | - commands.getoutput 379 | - commands.getstatusoutput 380 | subprocess: 381 | - subprocess.Popen 382 | - subprocess.call 383 | - subprocess.check_call 384 | - subprocess.check_output 385 | - subprocess.run 386 | try_except_continue: 387 | check_typed_exception: false 388 | try_except_pass: 389 | check_typed_exception: false 390 | weak_cryptographic_key: 391 | weak_key_size_dsa_high: 1024 392 | weak_key_size_dsa_medium: 2048 393 | weak_key_size_ec_high: 160 394 | weak_key_size_ec_medium: 224 395 | weak_key_size_rsa_high: 1024 396 | weak_key_size_rsa_medium: 2048 397 | 398 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - python 8 | fixme: 9 | enabled: true 10 | radon: 11 | enabled: true 12 | ratings: 13 | paths: 14 | - "**.py" 15 | exclude_paths: 16 | - dist/ 17 | - docs/ 18 | - tests/ 19 | -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/eufy-security-ws-python/531e07d6fed3e8c1ed047fe8fd7057300dd9f142/.coverage -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | raise AssertionError 6 | raise NotImplementedError 7 | if TYPE_CHECKING: 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, F811, W503 3 | max-line-length = 80 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Number of labels to fetch (optional). Defaults to 20 3 | numLabels: 40 4 | # These labels will not be used even if the issue contains them (optional). 5 | # Pass a blank array if no labels are to be excluded. 6 | # excludeLabels: [] 7 | excludeLabels: 8 | - pinned 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Describe what the PR does:** 2 | 3 | **Does this fix a specific issue?** 4 | 5 | Fixes https://github.com/bachya/eufy-security-ws-python/issues/ 6 | 7 | **Checklist:** 8 | 9 | - [ ] Confirm that one or more new tests are written for the new functionality. 10 | - [ ] Update `README.md` with any new documentation. 11 | - [ ] Add yourself to `AUTHORS.md`. 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | categories: 3 | - title: "🚨 Breaking Changes" 4 | labels: 5 | - "breaking change" 6 | - title: "🚀 Features" 7 | labels: 8 | - "enhancement" 9 | - title: "🐛 Bug Fixes" 10 | labels: 11 | - "bug" 12 | - title: "🧰 Maintenance" 13 | labels: 14 | - "ci/cd" 15 | - "dependencies" 16 | - "maintenance" 17 | - "tooling" 18 | change-template: "- $TITLE (#$NUMBER)" 19 | name-template: "$NEXT_PATCH_VERSION" 20 | tag-template: "$NEXT_PATCH_VERSION" 21 | template: | 22 | $CHANGES 23 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration for probot-stale - https://github.com/probot/stale 3 | 4 | # Number of days of inactivity before an Issue or Pull Request becomes stale 5 | daysUntilStale: 90 6 | 7 | # Number of days of inactivity before an Issue or Pull Request with the stale 8 | # label is closed. Set to false to disable. If disabled, issues still need to 9 | # be closed manually, but will remain marked as stale. 10 | daysUntilClose: 7 11 | 12 | # Only issues or pull requests with all of these labels are check if stale. 13 | # Defaults to `[]` (disabled) 14 | onlyLabels: [] 15 | 16 | # Issues or Pull Requests with these labels will never be considered stale. 17 | # Set to `[]` to disable 18 | exemptLabels: 19 | - help wanted 20 | 21 | # Set to true to ignore issues in a project (defaults to false) 22 | exemptProjects: true 23 | 24 | # Set to true to ignore issues in a milestone (defaults to false) 25 | exemptMilestones: true 26 | 27 | # Set to true to ignore issues with an assignee (defaults to false) 28 | exemptAssignees: false 29 | 30 | # Label to use when marking as stale 31 | staleLabel: stale 32 | 33 | # Comment to post when marking as stale. Set to `false` to disable 34 | markComment: > 35 | This issue has been automatically marked as stale because it has not had 36 | recent activity. It will be closed if no further activity occurs. Thank you 37 | for your contributions. 38 | 39 | # Comment to post when removing the stale label. 40 | # unmarkComment: > 41 | # Your comment here. 42 | 43 | # Comment to post when closing a stale Issue or Pull Request. 44 | # closeComment: > 45 | # Your comment here. 46 | 47 | # Limit the number of actions per hour, from 1-30. Default is 30 48 | limitPerRun: 30 49 | 50 | # Limit to only `issues` or `pulls` 51 | # only: issues 52 | 53 | # Handle pull requests a little bit faster and with an adjusted comment. 54 | pulls: 55 | daysUntilStale: 30 56 | exemptProjects: false 57 | markComment: > 58 | This pull request has been automatically marked as stale because it has not 59 | had recent activity. It will be closed if no further activity occurs. Thank 60 | you for your contributions. 61 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - dev 8 | - master 9 | 10 | push: 11 | branches: 12 | - dev 13 | - master 14 | 15 | jobs: 16 | test: 17 | name: Tests 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | python-version: 24 | - "3.8" 25 | - "3.9" 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | 30 | - uses: actions/setup-python@v2 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | architecture: x64 34 | 35 | - run: | 36 | python -m venv venv 37 | venv/bin/pip install -r requirements_test.txt 38 | venv/bin/py.test tests/ 39 | 40 | coverage: 41 | 42 | name: Test Coverage 43 | 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v2 48 | 49 | - uses: actions/setup-python@v2 50 | with: 51 | python-version: "3.x" 52 | architecture: x64 53 | 54 | - run: | 55 | python -m venv venv 56 | venv/bin/pip install -r requirements_test.txt 57 | venv/bin/py.test \ 58 | -s \ 59 | --verbose \ 60 | --cov-report term-missing \ 61 | --cov-report xml \ 62 | --cov=eufy-security-ws-python tests 63 | 64 | - uses: codecov/codecov-action@v2 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | 68 | lint: 69 | 70 | name: "Linting & Static Analysis" 71 | 72 | runs-on: ubuntu-latest 73 | 74 | steps: 75 | - uses: actions/checkout@v2 76 | 77 | - uses: actions/setup-python@v2 78 | with: 79 | python-version: "3.x" 80 | architecture: x64 81 | 82 | - uses: pre-commit/action@v2.0.3 83 | env: 84 | SKIP: no-commit-to-branch 85 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Publish to PyPI" 3 | 4 | on: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | publish_to_pypi: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Python 3.7 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.7 22 | 23 | - name: Publish to PyPI 24 | run: | 25 | pip install poetry 26 | poetry publish --build -u __token__ -p ${{ secrets.PYPI_API_KEY }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Draft Release" 3 | 4 | on: 5 | push: 6 | branches: 7 | - "dev" 8 | 9 | jobs: 10 | update_release_draft: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: release-drafter/release-drafter@v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | coverage.xml 4 | dist 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/PyCQA/bandit 4 | rev: 1.6.2 5 | hooks: 6 | - id: bandit 7 | args: 8 | - --quiet 9 | - --format=custom 10 | - --configfile=.bandit.yaml 11 | files: ^eufy-security-ws-python/.+\.py$ 12 | - repo: https://github.com/python/black 13 | rev: 19.10b0 14 | hooks: 15 | - id: black 16 | args: 17 | - --safe 18 | - --quiet 19 | language_version: python3 20 | files: ^((eufy-security-ws-python|tests)/.+)?[^/]+\.py$ 21 | - repo: https://github.com/codespell-project/codespell 22 | rev: v1.16.0 23 | hooks: 24 | - id: codespell 25 | args: 26 | - --skip="./.*,*.json" 27 | - --quiet-level=4 28 | exclude_types: [json] 29 | - repo: https://gitlab.com/pycqa/flake8 30 | rev: 3.7.9 31 | hooks: 32 | - id: flake8 33 | additional_dependencies: 34 | - flake8-docstrings==1.5.0 35 | - pydocstyle==5.0.1 36 | files: ^eufy-security-ws-python/.+\.py$ 37 | - repo: https://github.com/pre-commit/mirrors-isort 38 | rev: v4.3.21 39 | hooks: 40 | - id: isort 41 | additional_dependencies: 42 | - toml 43 | files: ^(eufy-security-ws-python|tests)/.+\.py$ 44 | - repo: https://github.com/pre-commit/mirrors-mypy 45 | rev: v0.790 46 | hooks: 47 | - id: mypy 48 | files: ^eufy-security-ws-python/.+\.py$ 49 | - repo: https://github.com/pre-commit/pre-commit-hooks 50 | rev: v2.4.0 51 | hooks: 52 | - id: check-json 53 | - id: no-commit-to-branch 54 | args: 55 | - --branch=dev 56 | - --branch=master 57 | - repo: https://github.com/PyCQA/pydocstyle 58 | rev: 5.0.2 59 | hooks: 60 | - id: pydocstyle 61 | files: ^((eufy-security-ws-python|tests)/.+)?[^/]+\.py$ 62 | - repo: https://github.com/gruntwork-io/pre-commit 63 | rev: v0.1.12 64 | hooks: 65 | - id: shellcheck 66 | files: ^script/.+ 67 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "scanSettings": { 3 | "baseBranches": [] 4 | }, 5 | "checkRunSettings": { 6 | "vulnerableCheckRunConclusionLevel": "failure", 7 | "displayMode": "diff" 8 | }, 9 | "issueSettings": { 10 | "minSeverityLevel": "LOW" 11 | } 12 | } -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Contributions to `eufy-websocket-ws-python` 2 | 3 | ## Owners 4 | 5 | - Aaron Bach (https://github.com/bachya) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Aaron Bach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚨 eufy-security-ws-python: A Python wrapper around eufy-security-ws 2 | 3 | **NOTE: THIS PROJECT IS NO LONGER ACTIVE. AS AN ALTERNATIVE, PLEASE CHECK OUT https://github.com/fuatakgun/eufy_security.** 4 | 5 | [![CI](https://github.com/bachya/eufy-security-ws-python/workflows/CI/badge.svg)](https://github.com/bachya/eufy-security-ws-python/actions) 6 | [![PyPi](https://img.shields.io/pypi/v/eufy-security-ws-python.svg)](https://pypi.python.org/pypi/eufy-security-ws-python) 7 | [![Version](https://img.shields.io/pypi/pyversions/eufy-security-ws-python.svg)](https://pypi.python.org/pypi/eufy-security-ws-python) 8 | [![License](https://img.shields.io/pypi/l/eufy-security-ws-python.svg)](https://github.com/bachya/eufy-security-ws-python/blob/master/LICENSE) 9 | [![Code Coverage](https://codecov.io/gh/bachya/eufy-security-ws-python/branch/dev/graph/badge.svg)](https://codecov.io/gh/bachya/eufy-security-ws-python) 10 | [![Maintainability](https://api.codeclimate.com/v1/badges/81a9f8274abf325b2fa4/maintainability)](https://codeclimate.com/github/bachya/eufy-security-ws-python/maintainability) 11 | [![Say Thanks](https://img.shields.io/badge/SayThanks-!-1EAEDB.svg)](https://saythanks.io/to/bachya) 12 | 13 | `eufy-security-ws-python` is a simple Python wrapper around [`https://github.com/bropat/eufy-security-ws`](https://github.com/bropat/eufy-security-ws). 14 | 15 | # Installation 16 | 17 | ```python 18 | pip install eufy-security-ws-python 19 | ``` 20 | 21 | # Python Versions 22 | 23 | `eufy-security-ws-python` is currently supported on: 24 | 25 | * Python 3.8 26 | * Python 3.9 27 | 28 | # Contributing 29 | 30 | 1. [Check for open features/bugs](https://github.com/bachya/eufy-security-ws-python/issues) 31 | or [initiate a discussion on one](https://github.com/bachya/eufy-security-ws-python/issues/new). 32 | 2. [Fork the repository](https://github.com/bachya/eufy-security-ws-python/fork). 33 | 3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv` 34 | 4. (_optional, but highly recommended_) Enter the virtual environment: `source ./venv/bin/activate` 35 | 5. Install the dev environment: `script/setup` 36 | 6. Code your new feature or bug fix. 37 | 7. Write tests that cover your new functionality. 38 | 8. Run tests and ensure 100% code coverage: `script/test` 39 | 9. Update `README.md` with any new documentation. 40 | 10. Add yourself to `AUTHORS.md`. 41 | 11. Submit a pull request! 42 | -------------------------------------------------------------------------------- /eufy_security_ws_python/__init__.py: -------------------------------------------------------------------------------- 1 | """Define the eufy-security-ws-python package.""" 2 | -------------------------------------------------------------------------------- /eufy_security_ws_python/client.py: -------------------------------------------------------------------------------- 1 | """Define a client to connect to the websocket server.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | from types import TracebackType 6 | from typing import Any, Optional, cast 7 | import uuid 8 | 9 | from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType 10 | from aiohttp.client_exceptions import ( 11 | ClientError, 12 | ServerDisconnectedError, 13 | WSServerHandshakeError, 14 | ) 15 | 16 | from eufy_security_ws_python.const import ( 17 | LOGGER, 18 | MIN_SERVER_SCHEMA_VERSION, 19 | MAX_SERVER_SCHEMA_VERSION, 20 | ) 21 | from eufy_security_ws_python.errors import ( 22 | CannotConnectError, 23 | ConnectionClosed, 24 | ConnectionFailed, 25 | FailedCommand, 26 | InvalidMessage, 27 | InvalidServerVersion, 28 | NotConnectedError, 29 | ) 30 | from eufy_security_ws_python.event import Event 31 | from eufy_security_ws_python.model.driver import Driver 32 | from eufy_security_ws_python.model.version import VersionInfo 33 | 34 | SIZE_PARSE_JSON_EXECUTOR = 8192 35 | 36 | 37 | class WebsocketClient: # pylint: disable=too-many-instance-attributes 38 | """Define a websocket manager.""" 39 | 40 | def __init__(self, ws_server_uri: str, session: ClientSession) -> None: 41 | """Initialize.""" 42 | self._client: Optional[ClientWebSocketResponse] = None 43 | self._loop = asyncio.get_running_loop() 44 | self._result_futures: dict[str, asyncio.Future] = {} 45 | self._session = session 46 | self._shutdown_complete_event: Optional[asyncio.Event] = None 47 | self._ws_server_uri = ws_server_uri 48 | self.driver: Optional[Driver] = None 49 | self.schema_version = MAX_SERVER_SCHEMA_VERSION 50 | self.version: Optional[VersionInfo] = None 51 | 52 | async def __aenter__(self) -> "WebsocketClient": 53 | """Connect to the websocket.""" 54 | await self.async_connect() 55 | return self 56 | 57 | async def __aexit__( 58 | self, exc_type: Exception, exc_value: str, traceback: TracebackType 59 | ) -> None: 60 | """Disconnect from the websocket.""" 61 | await self.async_disconnect() 62 | 63 | @property 64 | def connected(self) -> bool: 65 | """Return if current connected to the websocket.""" 66 | return self._client is not None and not self._client.closed 67 | 68 | def _parse_response_payload(self, payload: dict) -> None: 69 | """Handle a message from the websocket server.""" 70 | if payload["type"] == "result": 71 | future = self._result_futures.get(payload["messageId"]) 72 | 73 | if future is None: 74 | return 75 | 76 | if payload["success"]: 77 | future.set_result(payload["result"]) 78 | return 79 | 80 | err = FailedCommand(payload["messageId"], payload["errorCode"]) 81 | future.set_exception(err) 82 | return 83 | 84 | if payload["type"] != "event": 85 | LOGGER.debug( 86 | "Received message with unknown type '%s': %s", 87 | payload["type"], 88 | payload, 89 | ) 90 | return 91 | 92 | event = Event(type=payload["event"]["event"], data=payload["event"]) 93 | self.driver.receive_event(event) 94 | 95 | async def _async_receive_json(self) -> dict: 96 | """Receive a JSON response from the websocket server.""" 97 | assert self._client 98 | msg = await self._client.receive() 99 | 100 | if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): 101 | raise ConnectionClosed("Connection was closed.") 102 | 103 | if msg.type == WSMsgType.ERROR: 104 | raise ConnectionFailed() 105 | 106 | if msg.type != WSMsgType.TEXT: 107 | raise InvalidMessage(f"Received non-text message: {msg.type}") 108 | 109 | try: 110 | if len(msg.data) > SIZE_PARSE_JSON_EXECUTOR: 111 | data = await self._loop.run_in_executor(None, msg.json) 112 | else: 113 | data = msg.json() 114 | except ValueError as err: 115 | raise InvalidMessage("Received invalid JSON") from err 116 | 117 | LOGGER.debug("Received data from websocket server: %s", data) 118 | 119 | return data 120 | 121 | async def _async_send_json(self, payload: dict[str, Any]) -> None: 122 | """Send a JSON message to the websocket server. 123 | 124 | Raises NotConnectedError if client is not connected. 125 | """ 126 | if not self.connected: 127 | raise NotConnectedError 128 | 129 | assert self._client 130 | assert "messageId" in payload 131 | 132 | LOGGER.debug("Sending data to websocket server: %s", payload) 133 | 134 | await self._client.send_json(payload) 135 | 136 | async def _async_set_api_schema(self) -> None: 137 | """Set the API schema version on server.""" 138 | assert self._client 139 | 140 | await self._async_send_json( 141 | { 142 | "command": "set_api_schema", 143 | "messageId": "set_api_schema", 144 | "schemaVersion": self.schema_version, 145 | } 146 | ) 147 | 148 | set_api_msg = await self._async_receive_json() 149 | 150 | if not set_api_msg["success"]: 151 | await self._client.close() 152 | raise FailedCommand(set_api_msg["messageId"], set_api_msg["errorCode"]) 153 | 154 | async def async_connect(self) -> None: 155 | """Connect to the websocket server.""" 156 | LOGGER.debug("Connecting to websocket server") 157 | 158 | try: 159 | self._client = await self._session.ws_connect( 160 | self._ws_server_uri, heartbeat=55 161 | ) 162 | except ServerDisconnectedError as err: 163 | raise ConnectionClosed from err 164 | except (ClientError, WSServerHandshakeError) as err: 165 | raise CannotConnectError from err 166 | 167 | self.version = VersionInfo.from_message(await self._async_receive_json()) 168 | 169 | if ( 170 | self.version.min_schema_version > MIN_SERVER_SCHEMA_VERSION 171 | or self.version.max_schema_version < MAX_SERVER_SCHEMA_VERSION 172 | ): 173 | await self._client.close() 174 | raise InvalidServerVersion( 175 | f"eufy-websocket-js version is incompatible: {self.version.server_version}. " 176 | "Update eufy-websocket-ws to a version that supports a minimum API " 177 | f"schema of {MIN_SERVER_SCHEMA_VERSION}." 178 | ) 179 | 180 | # Negotiate the highest available schema version and guard incompatibility with 181 | # the MIN_SERVER_SCHEMA_VERSION: 182 | if self.version.max_schema_version < MAX_SERVER_SCHEMA_VERSION: 183 | self.schema_version = self.version.max_schema_version 184 | 185 | LOGGER.info( 186 | "Connected to %s (Server %s, Driver %s, Using Schema %s)", 187 | self._ws_server_uri, 188 | self.version.server_version, 189 | self.version.driver_version, 190 | self.schema_version, 191 | ) 192 | 193 | async def async_disconnect(self) -> None: 194 | """Disconnect from the websocket server.""" 195 | if not self.connected: 196 | return 197 | 198 | LOGGER.debug("Disconnecting from websocket server") 199 | 200 | await self._client.close() 201 | 202 | async def async_listen(self, driver_ready: asyncio.Event) -> None: 203 | """Start listening to the websocket server. 204 | 205 | Raises NotConnectedError if client is not connected. 206 | """ 207 | if not self.connected: 208 | raise NotConnectedError 209 | 210 | assert self._client 211 | 212 | try: 213 | await self._async_set_api_schema() 214 | await self._async_send_json( 215 | {"command": "start_listening", "messageId": "start_listening"} 216 | ) 217 | 218 | state_msg = await self._async_receive_json() 219 | 220 | if not state_msg["success"]: 221 | await self._client.close() 222 | raise FailedCommand(state_msg["messageId"], state_msg["errorCode"]) 223 | 224 | self.driver = cast( 225 | Driver, 226 | await self._loop.run_in_executor( 227 | None, 228 | Driver, 229 | self, 230 | state_msg, 231 | ), 232 | ) 233 | driver_ready.set() 234 | 235 | LOGGER.info("Started listening to websocket server") 236 | 237 | while not self._client.closed: 238 | msg = await self._async_receive_json() 239 | self._parse_response_payload(msg) 240 | except ConnectionClosed: 241 | pass 242 | finally: 243 | LOGGER.debug("Listen completed; cleaning up") 244 | 245 | for future in self._result_futures.values(): 246 | future.cancel() 247 | 248 | if not self._client.closed: 249 | await self._client.close() 250 | 251 | if self._shutdown_complete_event: 252 | self._shutdown_complete_event.set() 253 | 254 | async def async_send_command( 255 | self, payload: dict[str, Any], *, require_schema: int = None 256 | ) -> dict: 257 | """Send a command to the websocket server and wait for a response.""" 258 | if require_schema and require_schema > self.schema_version: 259 | raise InvalidServerVersion( 260 | "Command unavailable due to an incompatible eufy-websocket-ws version. " 261 | "Update eufy-websocket-ws to a version that supports a minimum API " 262 | f"schema of {require_schema}." 263 | ) 264 | 265 | future: "asyncio.Future[dict]" = self._loop.create_future() 266 | message_id = payload["messageId"] = uuid.uuid4().hex 267 | self._result_futures[message_id] = future 268 | await self._async_send_json(payload) 269 | try: 270 | return await future 271 | finally: 272 | self._result_futures.pop(message_id) 273 | 274 | async def async_send_command_no_wait( 275 | self, payload: dict[str, Any], *, require_schema: int = None 276 | ) -> dict: 277 | """Send a command to the websocket server and don't wait for a response.""" 278 | if require_schema and require_schema > self.schema_version: 279 | raise InvalidServerVersion( 280 | "Command unavailable due to an incompatible eufy-websocket-ws version. " 281 | "Update eufy-websocket-ws to a version that supports a minimum API " 282 | f"schema of {require_schema}." 283 | ) 284 | 285 | payload["messageId"] = uuid.uuid4().hex 286 | await self._async_send_json(payload) 287 | -------------------------------------------------------------------------------- /eufy_security_ws_python/const.py: -------------------------------------------------------------------------------- 1 | """Define package constants.""" 2 | import logging 3 | 4 | LOGGER = logging.getLogger(__package__) 5 | 6 | MAX_SERVER_SCHEMA_VERSION = 1 7 | MIN_SERVER_SCHEMA_VERSION = 0 8 | -------------------------------------------------------------------------------- /eufy_security_ws_python/errors.py: -------------------------------------------------------------------------------- 1 | """Define package exceptions.""" 2 | from typing import Optional 3 | 4 | 5 | class BaseEufySecurityServerError(Exception): 6 | """Define a base error.""" 7 | 8 | pass 9 | 10 | 11 | class TransportError(BaseEufySecurityServerError): 12 | """Define a transport-related exception.""" 13 | 14 | 15 | class CannotConnectError(TransportError): 16 | """Define a error when the websocket can't be connected to.""" 17 | 18 | pass 19 | 20 | 21 | class ConnectionClosed(TransportError): 22 | """Define a error when the websocket closes unexpectedly.""" 23 | 24 | pass 25 | 26 | 27 | class ConnectionFailed(TransportError): 28 | """Define a error when the websocket connection fails.""" 29 | 30 | pass 31 | 32 | 33 | class FailedCommand(BaseEufySecurityServerError): 34 | """Define a error related to a failed command.""" 35 | 36 | def __init__(self, message_id: str, error_code: str, msg: Optional[str] = None): 37 | """Initialize a failed command error.""" 38 | super().__init__(msg or f"Command failed: {error_code}") 39 | self.message_id = message_id 40 | self.error_code = error_code 41 | 42 | 43 | class InvalidMessage(BaseEufySecurityServerError): 44 | """Define a error related to an invalid message from the websocket server.""" 45 | 46 | pass 47 | 48 | 49 | class InvalidServerVersion(BaseEufySecurityServerError): 50 | """Define a error related to an invalid eufy-websocket-ws schema version.""" 51 | 52 | pass 53 | 54 | 55 | class NotConnectedError(BaseEufySecurityServerError): 56 | """Define a error when the websocket hasn't been connected to.""" 57 | 58 | pass 59 | -------------------------------------------------------------------------------- /eufy_security_ws_python/event.py: -------------------------------------------------------------------------------- 1 | """Define a utilities related to websocket events.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass, field 5 | from typing import Callable 6 | 7 | from eufy_security_ws_python.const import LOGGER 8 | 9 | 10 | @dataclass 11 | class Event: 12 | """Define an event.""" 13 | 14 | type: str 15 | data: dict = field(default_factory=dict) 16 | 17 | 18 | class EventBase: 19 | """Define a base class for event handling.""" 20 | 21 | def __init__(self) -> None: 22 | """Initialize event base.""" 23 | self._listeners: dict[str, list[Callable]] = {} 24 | 25 | def on( # pylint: disable=invalid-name 26 | self, event_name: str, callback: Callable 27 | ) -> Callable: 28 | """Register an event callback.""" 29 | listeners = self._listeners.setdefault(event_name, []) 30 | listeners.append(callback) 31 | 32 | def unsubscribe() -> None: 33 | """Unsubscribe listeners.""" 34 | if callback in listeners: 35 | listeners.remove(callback) 36 | 37 | return unsubscribe 38 | 39 | def once(self, event_name: str, callback: Callable) -> Callable: 40 | """Listen for an event exactly once.""" 41 | 42 | def event_listener(data: dict) -> None: 43 | unsub() 44 | callback(data) 45 | 46 | unsub = self.on(event_name, event_listener) 47 | 48 | return unsub 49 | 50 | def emit(self, event_name: str, data: dict) -> None: 51 | """Run all callbacks for an event.""" 52 | for listener in self._listeners.get(event_name, []): 53 | listener(data) 54 | 55 | def _handle_event_protocol(self, event: Event) -> None: 56 | """Process an event based on event protocol.""" 57 | handler = getattr(self, f"handle_{event.type.replace(' ', '_')}", None) 58 | 59 | if handler is None: 60 | LOGGER.debug("Received unknown event: %s", event) 61 | return 62 | 63 | handler(event) 64 | -------------------------------------------------------------------------------- /eufy_security_ws_python/model/__init__.py: -------------------------------------------------------------------------------- 1 | """Define representations of various eufy-security-ws objects.""" 2 | -------------------------------------------------------------------------------- /eufy_security_ws_python/model/device.py: -------------------------------------------------------------------------------- 1 | """Define a Eufy Security device.""" 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from eufy_security_ws_python.event import Event, EventBase 7 | 8 | if TYPE_CHECKING: 9 | from eufy_security_ws_python.client import WebsocketClient 10 | 11 | 12 | class Device(EventBase): 13 | """Define a base device.""" 14 | 15 | def __init__(self, client: "WebsocketClient", state: dict[str, Any]) -> None: 16 | """Initialize.""" 17 | super().__init__() 18 | 19 | self._client = client 20 | self._state = state 21 | 22 | def __repr__(self) -> str: 23 | """Return the representation.""" 24 | return f"<{type(self).__name__} name={self.name} serial={self.serial_number}>" 25 | 26 | def __hash__(self) -> int: 27 | """Return the hash.""" 28 | return hash(self.serial_number) 29 | 30 | def __eq__(self, other: object) -> bool: 31 | """Return whether this instance equals another.""" 32 | if not isinstance(other, Device): 33 | return False 34 | return self.serial_number == other.serial_number 35 | 36 | @property 37 | def enabled(self) -> bool: 38 | """Return whether the device is enabled.""" 39 | return self._state["enabled"] 40 | 41 | @property 42 | def hardware_version(self) -> str: 43 | """Return the hardware version.""" 44 | return self._state["hardwareVersion"] 45 | 46 | @property 47 | def model(self) -> str: 48 | """Return the model ID.""" 49 | return self._state["model"] 50 | 51 | @property 52 | def name(self) -> str: 53 | """Return the name.""" 54 | return self._state["name"] 55 | 56 | @property 57 | def serial_number(self) -> str: 58 | """Return the serial number.""" 59 | return self._state["serialNumber"] 60 | 61 | @property 62 | def software_version(self) -> str: 63 | """Return the software version.""" 64 | return self._state["softwareVersion"] 65 | 66 | @property 67 | def station_serial_number(self) -> str: 68 | """Return the serial number of the station.""" 69 | return self._state["stationSerialNumber"] 70 | 71 | @property 72 | def type(self) -> str: 73 | """Return the type.""" 74 | return self._state["type"] 75 | 76 | async def async_get_properties_metadata(self) -> dict[str, Any]: 77 | """Get all properties metadata for this device.""" 78 | return await self._client.async_send_command( 79 | { 80 | "command": "device.get_properties_metadata", 81 | "serialNumber": self.serial_number, 82 | } 83 | ) 84 | 85 | def handle_property_changed(self, event: Event) -> None: 86 | """Handle a "property changed" event.""" 87 | self._state[event.data["name"]] = event.data["value"] 88 | 89 | def receive_event(self, event: Event) -> None: 90 | """React to an event.""" 91 | self._handle_event_protocol(event) 92 | -------------------------------------------------------------------------------- /eufy_security_ws_python/model/driver.py: -------------------------------------------------------------------------------- 1 | """Define the eufy-security-ws driver.""" 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | from eufy_security_ws_python.event import Event, EventBase 7 | from eufy_security_ws_python.model.device import Device 8 | from eufy_security_ws_python.model.station import Station 9 | 10 | if TYPE_CHECKING: 11 | from eufy_security_ws_python.client import WebsocketClient 12 | 13 | 14 | class Driver(EventBase): 15 | """Define the driver.""" 16 | 17 | def __init__(self, client: "WebsocketClient", state: dict) -> None: 18 | """Initialize.""" 19 | super().__init__() 20 | 21 | self._state = state 22 | self.stations: dict[str, Device] = { 23 | station_state["serialNumber"]: Station(client, station_state) 24 | for station_state in state["result"]["state"]["stations"] 25 | } 26 | self.devices: dict[str, Station] = { 27 | device_state["serialNumber"]: Device(client, device_state) 28 | for device_state in state["result"]["state"]["devices"] 29 | } 30 | 31 | @property 32 | def connected(self) -> bool: 33 | """Return whether the driver is connected.""" 34 | return self._state["result"]["state"]["driver"]["connected"] 35 | 36 | @property 37 | def push_connected(self) -> bool: 38 | """Return whether the driver is connected to push events.""" 39 | return self._state["result"]["state"]["driver"]["pushConnected"] 40 | 41 | @property 42 | def version(self) -> bool: 43 | """Return the version.""" 44 | return self._state["result"]["state"]["driver"]["version"] 45 | 46 | def receive_event(self, event: Event) -> None: 47 | """React to an event.""" 48 | if event.data["source"] == "station": 49 | station = self.stations[event.data["serialNumber"]] 50 | station.receive_event(event) 51 | elif event.data["source"] == "device": 52 | device = self.devices[event.data["serialNumber"]] 53 | device.receive_event(event) 54 | else: 55 | self._handle_event_protocol(event) 56 | 57 | self.emit(event.type, event.data) 58 | -------------------------------------------------------------------------------- /eufy_security_ws_python/model/station.py: -------------------------------------------------------------------------------- 1 | """Define a Eufy Security base station.""" 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from eufy_security_ws_python.event import Event, EventBase 7 | 8 | if TYPE_CHECKING: 9 | from eufy_security_ws_python.client import WebsocketClient 10 | 11 | 12 | class Station(EventBase): 13 | """Define the station.""" 14 | 15 | def __init__(self, client: "WebsocketClient", state: dict[str, Any]) -> None: 16 | """Initialize.""" 17 | super().__init__() 18 | 19 | self._client = client 20 | self._state = state 21 | 22 | def __repr__(self) -> str: 23 | """Return the representation.""" 24 | return f"<{type(self).__name__} name={self.name} serial={self.serial_number}>" 25 | 26 | def __hash__(self) -> int: 27 | """Return the hash.""" 28 | return hash(self.serial_number) 29 | 30 | def __eq__(self, other: object) -> bool: 31 | """Return whether this instance equals another.""" 32 | if not isinstance(other, Station): 33 | return False 34 | return self.serial_number == other.serial_number 35 | 36 | @property 37 | def connected(self) -> bool: 38 | """Return whether the station is connected and online.""" 39 | return self._state["connected"] 40 | 41 | @property 42 | def alarm_mode(self) -> int: 43 | """Return the current alarm mode.""" 44 | return self._state["currentMode"] 45 | 46 | @property 47 | def guard_mode(self) -> int: 48 | """Return the current guard mode.""" 49 | return self._state["guardMode"] 50 | 51 | @property 52 | def hardware_version(self) -> str: 53 | """Return the hardware version.""" 54 | return self._state["hardwareVersion"] 55 | 56 | @property 57 | def lan_ip_address(self) -> str: 58 | """Return the LAN IP address.""" 59 | return self._state["lanIpAddress"] 60 | 61 | @property 62 | def mac_address(self) -> str: 63 | """Return the MAC address.""" 64 | return self._state["macAddress"] 65 | 66 | @property 67 | def model(self) -> str: 68 | """Return the model ID.""" 69 | return self._state["model"] 70 | 71 | @property 72 | def name(self) -> str: 73 | """Return the name.""" 74 | return self._state["name"] 75 | 76 | @property 77 | def serial_number(self) -> str: 78 | """Return the serial number.""" 79 | return self._state["serialNumber"] 80 | 81 | @property 82 | def software_version(self) -> str: 83 | """Return the software version.""" 84 | return self._state["softwareVersion"] 85 | 86 | @property 87 | def type(self) -> str: 88 | """Return the type.""" 89 | return self._state["type"] 90 | 91 | async def async_get_properties_metadata(self) -> dict[str, Any]: 92 | """Get all properties metadata for this station.""" 93 | return await self._client.async_send_command( 94 | { 95 | "command": "station.get_properties_metadata", 96 | "serialNumber": self.serial_number, 97 | } 98 | ) 99 | 100 | def handle_connected(self, _: Event) -> None: 101 | """Handle a "connected" event.""" 102 | 103 | def handle_disconnected(self, _: Event) -> None: 104 | """Handle a "disconnected" event.""" 105 | 106 | def handle_guard_mode_changed(self, _: Event) -> None: 107 | """Handle a "guard mode changed" event.""" 108 | 109 | def handle_property_changed(self, event: Event) -> None: 110 | """Handle a "property changed" event.""" 111 | self._state[event.data["name"]] = event.data["value"] 112 | 113 | def receive_event(self, event: Event) -> None: 114 | """React to an event.""" 115 | self._handle_event_protocol(event) 116 | -------------------------------------------------------------------------------- /eufy_security_ws_python/model/version.py: -------------------------------------------------------------------------------- 1 | """Define utilities related to eufy-websocket-ws versions.""" 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass 6 | class VersionInfo: 7 | """Define the server's version info.""" 8 | 9 | driver_version: str 10 | server_version: str 11 | min_schema_version: int 12 | max_schema_version: int 13 | 14 | @classmethod 15 | def from_message(cls, msg: dict) -> "VersionInfo": 16 | """Create an instance from a version message.""" 17 | return cls( 18 | driver_version=msg["driverVersion"], 19 | server_version=msg["serverVersion"], 20 | min_schema_version=msg.get("minSchemaVersion", 0), 21 | max_schema_version=msg.get("maxSchemaVersion", 0), 22 | ) 23 | -------------------------------------------------------------------------------- /eufy_security_ws_python/version.py: -------------------------------------------------------------------------------- 1 | """Define a version helper.""" 2 | import aiohttp 3 | 4 | from eufy_security_ws_python.model.version import VersionInfo 5 | 6 | 7 | async def async_get_server_version( 8 | url: str, session: aiohttp.ClientSession 9 | ) -> VersionInfo: 10 | """Return a server version.""" 11 | client = await session.ws_connect(url) 12 | try: 13 | return VersionInfo.from_message(await client.receive_json()) 14 | finally: 15 | await client.close() 16 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Define examples.""" 2 | -------------------------------------------------------------------------------- /examples/test_websocket.py: -------------------------------------------------------------------------------- 1 | """Define a websocket test.""" 2 | import asyncio 3 | import logging 4 | 5 | from aiohttp import ClientSession 6 | 7 | from eufy_security_ws_python.client import WebsocketClient 8 | from eufy_security_ws_python.errors import CannotConnectError 9 | 10 | _LOGGER = logging.getLogger() 11 | 12 | 13 | async def main() -> None: 14 | """Run the websocket example.""" 15 | logging.basicConfig(level=logging.DEBUG) 16 | 17 | async with ClientSession() as session: 18 | client = WebsocketClient("ws://localhost:3000", session) 19 | 20 | try: 21 | await client.async_connect() 22 | except CannotConnectError as err: 23 | _LOGGER.error("There was a error while connecting to the server: %s", err) 24 | return 25 | 26 | driver_ready = asyncio.Event() 27 | await client.async_listen(driver_ready) 28 | 29 | 30 | asyncio.run(main()) 31 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aiohttp" 3 | version = "3.7.4.post0" 4 | description = "Async http client/server framework (asyncio)" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [package.dependencies] 10 | async-timeout = ">=3.0,<4.0" 11 | attrs = ">=17.3.0" 12 | chardet = ">=2.0,<5.0" 13 | idna-ssl = {version = ">=1.0", markers = "python_version < \"3.7\""} 14 | multidict = ">=4.5,<7.0" 15 | typing-extensions = ">=3.6.5" 16 | yarl = ">=1.0,<2.0" 17 | 18 | [package.extras] 19 | speedups = ["aiodns", "brotlipy", "cchardet"] 20 | 21 | [[package]] 22 | name = "appdirs" 23 | version = "1.4.4" 24 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 25 | category = "dev" 26 | optional = false 27 | python-versions = "*" 28 | 29 | [[package]] 30 | name = "async-timeout" 31 | version = "3.0.1" 32 | description = "Timeout context manager for asyncio programs" 33 | category = "main" 34 | optional = false 35 | python-versions = ">=3.5.3" 36 | 37 | [[package]] 38 | name = "atomicwrites" 39 | version = "1.4.0" 40 | description = "Atomic file writes." 41 | category = "dev" 42 | optional = false 43 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 44 | 45 | [[package]] 46 | name = "attrs" 47 | version = "21.2.0" 48 | description = "Classes Without Boilerplate" 49 | category = "main" 50 | optional = false 51 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 52 | 53 | [package.extras] 54 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] 55 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 56 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] 57 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] 58 | 59 | [[package]] 60 | name = "cfgv" 61 | version = "3.3.0" 62 | description = "Validate configuration and produce human readable error messages." 63 | category = "dev" 64 | optional = false 65 | python-versions = ">=3.6.1" 66 | 67 | [[package]] 68 | name = "chardet" 69 | version = "4.0.0" 70 | description = "Universal encoding detector for Python 2 and 3" 71 | category = "main" 72 | optional = false 73 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 74 | 75 | [[package]] 76 | name = "colorama" 77 | version = "0.4.4" 78 | description = "Cross-platform colored terminal text." 79 | category = "dev" 80 | optional = false 81 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 82 | 83 | [[package]] 84 | name = "coverage" 85 | version = "6.2" 86 | description = "Code coverage measurement for Python" 87 | category = "dev" 88 | optional = false 89 | python-versions = ">=3.6" 90 | 91 | [package.dependencies] 92 | tomli = {version = "*", optional = true, markers = "extra == \"toml\""} 93 | 94 | [package.extras] 95 | toml = ["tomli"] 96 | 97 | [[package]] 98 | name = "distlib" 99 | version = "0.3.2" 100 | description = "Distribution utilities" 101 | category = "dev" 102 | optional = false 103 | python-versions = "*" 104 | 105 | [[package]] 106 | name = "filelock" 107 | version = "3.0.12" 108 | description = "A platform independent file lock." 109 | category = "dev" 110 | optional = false 111 | python-versions = "*" 112 | 113 | [[package]] 114 | name = "identify" 115 | version = "2.2.10" 116 | description = "File identification library for Python" 117 | category = "dev" 118 | optional = false 119 | python-versions = ">=3.6.1" 120 | 121 | [package.extras] 122 | license = ["editdistance-s"] 123 | 124 | [[package]] 125 | name = "idna" 126 | version = "3.2" 127 | description = "Internationalized Domain Names in Applications (IDNA)" 128 | category = "main" 129 | optional = false 130 | python-versions = ">=3.5" 131 | 132 | [[package]] 133 | name = "idna-ssl" 134 | version = "1.1.0" 135 | description = "Patch ssl.match_hostname for Unicode(idna) domains support" 136 | category = "main" 137 | optional = false 138 | python-versions = "*" 139 | 140 | [package.dependencies] 141 | idna = ">=2.0" 142 | 143 | [[package]] 144 | name = "importlib-metadata" 145 | version = "4.5.0" 146 | description = "Read metadata from Python packages" 147 | category = "dev" 148 | optional = false 149 | python-versions = ">=3.6" 150 | 151 | [package.dependencies] 152 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 153 | zipp = ">=0.5" 154 | 155 | [package.extras] 156 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 157 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 158 | 159 | [[package]] 160 | name = "importlib-resources" 161 | version = "5.1.4" 162 | description = "Read resources from Python packages" 163 | category = "dev" 164 | optional = false 165 | python-versions = ">=3.6" 166 | 167 | [package.dependencies] 168 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 169 | 170 | [package.extras] 171 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 172 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] 173 | 174 | [[package]] 175 | name = "iniconfig" 176 | version = "1.1.1" 177 | description = "iniconfig: brain-dead simple config-ini parsing" 178 | category = "dev" 179 | optional = false 180 | python-versions = "*" 181 | 182 | [[package]] 183 | name = "multidict" 184 | version = "5.1.0" 185 | description = "multidict implementation" 186 | category = "main" 187 | optional = false 188 | python-versions = ">=3.6" 189 | 190 | [[package]] 191 | name = "nodeenv" 192 | version = "1.6.0" 193 | description = "Node.js virtual environment builder" 194 | category = "dev" 195 | optional = false 196 | python-versions = "*" 197 | 198 | [[package]] 199 | name = "packaging" 200 | version = "20.9" 201 | description = "Core utilities for Python packages" 202 | category = "dev" 203 | optional = false 204 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 205 | 206 | [package.dependencies] 207 | pyparsing = ">=2.0.2" 208 | 209 | [[package]] 210 | name = "pluggy" 211 | version = "0.13.1" 212 | description = "plugin and hook calling mechanisms for python" 213 | category = "dev" 214 | optional = false 215 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 216 | 217 | [package.dependencies] 218 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 219 | 220 | [package.extras] 221 | dev = ["pre-commit", "tox"] 222 | 223 | [[package]] 224 | name = "pre-commit" 225 | version = "2.13.0" 226 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 227 | category = "dev" 228 | optional = false 229 | python-versions = ">=3.6.1" 230 | 231 | [package.dependencies] 232 | cfgv = ">=2.0.0" 233 | identify = ">=1.0.0" 234 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 235 | importlib-resources = {version = "*", markers = "python_version < \"3.7\""} 236 | nodeenv = ">=0.11.1" 237 | pyyaml = ">=5.1" 238 | toml = "*" 239 | virtualenv = ">=20.0.8" 240 | 241 | [[package]] 242 | name = "py" 243 | version = "1.10.0" 244 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 245 | category = "dev" 246 | optional = false 247 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 248 | 249 | [[package]] 250 | name = "pyparsing" 251 | version = "2.4.7" 252 | description = "Python parsing module" 253 | category = "dev" 254 | optional = false 255 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 256 | 257 | [[package]] 258 | name = "pytest" 259 | version = "6.2.5" 260 | description = "pytest: simple powerful testing with Python" 261 | category = "dev" 262 | optional = false 263 | python-versions = ">=3.6" 264 | 265 | [package.dependencies] 266 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 267 | attrs = ">=19.2.0" 268 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 269 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 270 | iniconfig = "*" 271 | packaging = "*" 272 | pluggy = ">=0.12,<2.0" 273 | py = ">=1.8.2" 274 | toml = "*" 275 | 276 | [package.extras] 277 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 278 | 279 | [[package]] 280 | name = "pytest-aiohttp" 281 | version = "0.3.0" 282 | description = "pytest plugin for aiohttp support" 283 | category = "dev" 284 | optional = false 285 | python-versions = "*" 286 | 287 | [package.dependencies] 288 | aiohttp = ">=2.3.5" 289 | pytest = "*" 290 | 291 | [[package]] 292 | name = "pytest-asyncio" 293 | version = "0.16.0" 294 | description = "Pytest support for asyncio." 295 | category = "dev" 296 | optional = false 297 | python-versions = ">= 3.6" 298 | 299 | [package.dependencies] 300 | pytest = ">=5.4.0" 301 | 302 | [package.extras] 303 | testing = ["coverage", "hypothesis (>=5.7.1)"] 304 | 305 | [[package]] 306 | name = "pytest-cov" 307 | version = "3.0.0" 308 | description = "Pytest plugin for measuring coverage." 309 | category = "dev" 310 | optional = false 311 | python-versions = ">=3.6" 312 | 313 | [package.dependencies] 314 | coverage = {version = ">=5.2.1", extras = ["toml"]} 315 | pytest = ">=4.6" 316 | 317 | [package.extras] 318 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 319 | 320 | [[package]] 321 | name = "pyyaml" 322 | version = "5.4.1" 323 | description = "YAML parser and emitter for Python" 324 | category = "dev" 325 | optional = false 326 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 327 | 328 | [[package]] 329 | name = "six" 330 | version = "1.16.0" 331 | description = "Python 2 and 3 compatibility utilities" 332 | category = "dev" 333 | optional = false 334 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 335 | 336 | [[package]] 337 | name = "toml" 338 | version = "0.10.2" 339 | description = "Python Library for Tom's Obvious, Minimal Language" 340 | category = "dev" 341 | optional = false 342 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 343 | 344 | [[package]] 345 | name = "tomli" 346 | version = "1.2.2" 347 | description = "A lil' TOML parser" 348 | category = "dev" 349 | optional = false 350 | python-versions = ">=3.6" 351 | 352 | [[package]] 353 | name = "typing-extensions" 354 | version = "3.10.0.0" 355 | description = "Backported and Experimental Type Hints for Python 3.5+" 356 | category = "main" 357 | optional = false 358 | python-versions = "*" 359 | 360 | [[package]] 361 | name = "virtualenv" 362 | version = "20.4.7" 363 | description = "Virtual Python Environment builder" 364 | category = "dev" 365 | optional = false 366 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 367 | 368 | [package.dependencies] 369 | appdirs = ">=1.4.3,<2" 370 | distlib = ">=0.3.1,<1" 371 | filelock = ">=3.0.0,<4" 372 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 373 | importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} 374 | six = ">=1.9.0,<2" 375 | 376 | [package.extras] 377 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] 378 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] 379 | 380 | [[package]] 381 | name = "yarl" 382 | version = "1.6.3" 383 | description = "Yet another URL library" 384 | category = "main" 385 | optional = false 386 | python-versions = ">=3.6" 387 | 388 | [package.dependencies] 389 | idna = ">=2.0" 390 | multidict = ">=4.0" 391 | typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} 392 | 393 | [[package]] 394 | name = "zipp" 395 | version = "3.4.1" 396 | description = "Backport of pathlib-compatible object wrapper for zip files" 397 | category = "dev" 398 | optional = false 399 | python-versions = ">=3.6" 400 | 401 | [package.extras] 402 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 403 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 404 | 405 | [metadata] 406 | lock-version = "1.1" 407 | python-versions = "^3.6.1" 408 | content-hash = "78250c0e18aa92b2d7f28b68e2cbae5442cfb1cbd3eaa948c6da0ccfe7e2dc20" 409 | 410 | [metadata.files] 411 | aiohttp = [ 412 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5"}, 413 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8"}, 414 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"}, 415 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290"}, 416 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f"}, 417 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809"}, 418 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe"}, 419 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-win32.whl", hash = "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287"}, 420 | {file = "aiohttp-3.7.4.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc"}, 421 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87"}, 422 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0"}, 423 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970"}, 424 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f"}, 425 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde"}, 426 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c"}, 427 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8"}, 428 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-win32.whl", hash = "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f"}, 429 | {file = "aiohttp-3.7.4.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5"}, 430 | {file = "aiohttp-3.7.4.post0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf"}, 431 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df"}, 432 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213"}, 433 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4"}, 434 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009"}, 435 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5"}, 436 | {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013"}, 437 | {file = "aiohttp-3.7.4.post0-cp38-cp38-win32.whl", hash = "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16"}, 438 | {file = "aiohttp-3.7.4.post0-cp38-cp38-win_amd64.whl", hash = "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5"}, 439 | {file = "aiohttp-3.7.4.post0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b"}, 440 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd"}, 441 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439"}, 442 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22"}, 443 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a"}, 444 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb"}, 445 | {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb"}, 446 | {file = "aiohttp-3.7.4.post0-cp39-cp39-win32.whl", hash = "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9"}, 447 | {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, 448 | {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, 449 | ] 450 | appdirs = [ 451 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 452 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 453 | ] 454 | async-timeout = [ 455 | {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, 456 | {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, 457 | ] 458 | atomicwrites = [ 459 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 460 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 461 | ] 462 | attrs = [ 463 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 464 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 465 | ] 466 | cfgv = [ 467 | {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, 468 | {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, 469 | ] 470 | chardet = [ 471 | {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, 472 | {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, 473 | ] 474 | colorama = [ 475 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 476 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 477 | ] 478 | coverage = [ 479 | {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, 480 | {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, 481 | {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, 482 | {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, 483 | {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, 484 | {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, 485 | {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, 486 | {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, 487 | {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, 488 | {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, 489 | {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, 490 | {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, 491 | {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, 492 | {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, 493 | {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, 494 | {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, 495 | {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, 496 | {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, 497 | {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, 498 | {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, 499 | {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, 500 | {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, 501 | {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, 502 | {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, 503 | {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, 504 | {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, 505 | {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, 506 | {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, 507 | {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, 508 | {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, 509 | {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, 510 | {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, 511 | {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, 512 | {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, 513 | {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, 514 | {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, 515 | {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, 516 | {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, 517 | {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, 518 | {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, 519 | {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, 520 | {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, 521 | {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, 522 | {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, 523 | {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, 524 | {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, 525 | {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, 526 | ] 527 | distlib = [ 528 | {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, 529 | {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, 530 | ] 531 | filelock = [ 532 | {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, 533 | {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, 534 | ] 535 | identify = [ 536 | {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, 537 | {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, 538 | ] 539 | idna = [ 540 | {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, 541 | {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, 542 | ] 543 | idna-ssl = [ 544 | {file = "idna-ssl-1.1.0.tar.gz", hash = "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c"}, 545 | ] 546 | importlib-metadata = [ 547 | {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, 548 | {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, 549 | ] 550 | importlib-resources = [ 551 | {file = "importlib_resources-5.1.4-py3-none-any.whl", hash = "sha256:e962bff7440364183203d179d7ae9ad90cb1f2b74dcb84300e88ecc42dca3351"}, 552 | {file = "importlib_resources-5.1.4.tar.gz", hash = "sha256:54161657e8ffc76596c4ede7080ca68cb02962a2e074a2586b695a93a925d36e"}, 553 | ] 554 | iniconfig = [ 555 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 556 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 557 | ] 558 | multidict = [ 559 | {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, 560 | {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, 561 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, 562 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, 563 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, 564 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, 565 | {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, 566 | {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, 567 | {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, 568 | {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, 569 | {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, 570 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, 571 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, 572 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, 573 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, 574 | {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, 575 | {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, 576 | {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, 577 | {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, 578 | {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, 579 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, 580 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, 581 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, 582 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, 583 | {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, 584 | {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, 585 | {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, 586 | {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, 587 | {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, 588 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, 589 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, 590 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, 591 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, 592 | {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, 593 | {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, 594 | {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, 595 | {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, 596 | ] 597 | nodeenv = [ 598 | {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, 599 | {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, 600 | ] 601 | packaging = [ 602 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 603 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 604 | ] 605 | pluggy = [ 606 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 607 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 608 | ] 609 | pre-commit = [ 610 | {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, 611 | {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, 612 | ] 613 | py = [ 614 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 615 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 616 | ] 617 | pyparsing = [ 618 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 619 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 620 | ] 621 | pytest = [ 622 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 623 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 624 | ] 625 | pytest-aiohttp = [ 626 | {file = "pytest-aiohttp-0.3.0.tar.gz", hash = "sha256:c929854339637977375838703b62fef63528598bc0a9d451639eba95f4aaa44f"}, 627 | {file = "pytest_aiohttp-0.3.0-py3-none-any.whl", hash = "sha256:0b9b660b146a65e1313e2083d0d2e1f63047797354af9a28d6b7c9f0726fa33d"}, 628 | ] 629 | pytest-asyncio = [ 630 | {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, 631 | {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, 632 | ] 633 | pytest-cov = [ 634 | {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, 635 | {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, 636 | ] 637 | pyyaml = [ 638 | {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, 639 | {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, 640 | {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, 641 | {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, 642 | {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, 643 | {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, 644 | {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, 645 | {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, 646 | {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, 647 | {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, 648 | {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, 649 | {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, 650 | {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, 651 | {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, 652 | {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, 653 | {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, 654 | {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, 655 | {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, 656 | {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, 657 | {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, 658 | {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, 659 | {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, 660 | {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, 661 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, 662 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, 663 | {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, 664 | {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, 665 | {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, 666 | {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, 667 | ] 668 | six = [ 669 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 670 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 671 | ] 672 | toml = [ 673 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 674 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 675 | ] 676 | tomli = [ 677 | {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"}, 678 | {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"}, 679 | ] 680 | typing-extensions = [ 681 | {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, 682 | {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, 683 | {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, 684 | ] 685 | virtualenv = [ 686 | {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, 687 | {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, 688 | ] 689 | yarl = [ 690 | {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, 691 | {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, 692 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, 693 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, 694 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, 695 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, 696 | {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, 697 | {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, 698 | {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, 699 | {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, 700 | {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, 701 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, 702 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, 703 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, 704 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, 705 | {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, 706 | {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, 707 | {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, 708 | {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, 709 | {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, 710 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, 711 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, 712 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, 713 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, 714 | {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, 715 | {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, 716 | {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, 717 | {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, 718 | {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, 719 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, 720 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, 721 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, 722 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, 723 | {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, 724 | {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, 725 | {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, 726 | {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, 727 | ] 728 | zipp = [ 729 | {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, 730 | {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, 731 | ] 732 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | # Reasons disabled: 3 | # bad-continuation - Invalid attack on black 4 | # too-few-public-methods - They can't hold me down 5 | # unnecessary-pass - This can hurt readability 6 | disable= 7 | bad-continuation, 8 | too-few-public-methods, 9 | unnecessary-pass 10 | 11 | [REPORTS] 12 | reports=no 13 | 14 | [FORMAT] 15 | expected-line-ending-format=LF 16 | 17 | [SIMILARITIES] 18 | #Minimum lines number of a similarity. 19 | min-similarity-lines=6 20 | 21 | # Ignore comments when computing similarities. 22 | ignore-comments=yes 23 | 24 | # Ignore docstrings when computing similarities. 25 | ignore-docstrings=yes 26 | 27 | # Ignore imports when computing similarities. 28 | ignore-imports=no 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 35.0.2", "wheel >= 0.29.0", "poetry>=0.12"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.isort] 6 | combine_as_imports = true 7 | default_section = "THIRDPARTY" 8 | force_grid_wrap = 0 9 | force_sort_within_sections = true 10 | forced_separate = "tests" 11 | include_trailing_comma = true 12 | indent = " " 13 | known_first_party = "eufy_security_ws_python,examples,tests" 14 | line_length = 88 15 | multi_line_output = 3 16 | not_skip = "__init__.py" 17 | sections = "FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 18 | use_parentheses = true 19 | 20 | [tool.poetry] 21 | name = "eufy_security_ws_python" 22 | version = "0.0.4" 23 | description = "A Python wrapper around eufy-security-ws" 24 | readme = "README.md" 25 | authors = ["Aaron Bach "] 26 | license = "MIT" 27 | repository = "https://github.com/bachya/eufy-security-ws-python" 28 | classifiers = [ 29 | "License :: OSI Approved :: MIT License", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.7", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: Implementation :: CPython", 36 | "Programming Language :: Python :: Implementation :: PyPy", 37 | ] 38 | 39 | [tool.poetry.dependencies] 40 | python = "^3.6.1" 41 | aiohttp = "^3.7.4.post0" 42 | 43 | [tool.poetry.dev-dependencies] 44 | pre-commit = "^2.13.0" 45 | pytest = "^6.2.5" 46 | pytest-aiohttp = "^0.3.0" 47 | pytest-asyncio = "^0.16.0" 48 | pytest-cov = "^3.0.0" 49 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:all", 5 | "schedule:monthly", 6 | ":disableDependencyDashboard" 7 | ], 8 | "separateMinorPatch": true 9 | } 10 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.7.4 2 | asynctest==0.13.0 3 | pytest-aiohttp==0.3.0 4 | pytest-cov==2.8.1 5 | pytest==6.2.5 6 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ -z "$1" ]; then 5 | echo "Usage: script/release [patch | minor | major]" 6 | exit 1 7 | fi 8 | 9 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "dev" ]; then 10 | echo "Refusing to publish a release from a branch other than dev" 11 | exit 1 12 | fi 13 | 14 | if [ -z "$(command -v poetry)" ]; then 15 | echo "Poetry needs to be installed to run this script: pip3 install poetry" 16 | exit 1 17 | fi 18 | 19 | # Temporarily uninstall pre-commit hooks so that we can push to dev and master: 20 | pre-commit uninstall 21 | 22 | case "$1" in 23 | patch) 24 | poetry version patch 25 | ;; 26 | minor) 27 | poetry version minor 28 | ;; 29 | major) 30 | poetry version major 31 | ;; 32 | *) 33 | echo "Unknown release action: \"$1\"" 34 | exit 1 35 | ;; 36 | esac 37 | 38 | # Update the PyPI package version: 39 | new_version="$(poetry version | awk -F' ' '{ print $2 }')" 40 | git add pyproject.toml 41 | 42 | # Commit, tag, and push: 43 | git commit -m "Bump version to $new_version" 44 | git tag "$new_version" 45 | git push && git push --tags 46 | 47 | # Merge dev into master: 48 | git checkout master 49 | git merge dev 50 | git push 51 | git checkout dev 52 | 53 | # Re-initialize pre-commit: 54 | pre-commit install 55 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Install all dependencies: 5 | pip3 install poetry 6 | poetry lock 7 | poetry install 8 | 9 | # Install pre-commit hooks: 10 | pre-commit install 11 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Run pytest with coverage: 5 | py.test -s --verbose --cov-report term-missing --cov-report xml --cov=eufy_security_ws_python tests 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Define package tests.""" 2 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Define common test utilities.""" 2 | import os 3 | 4 | 5 | def load_fixture(filename): 6 | """Load a fixture.""" 7 | path = os.path.join(os.path.dirname(__file__), "fixtures", filename) 8 | with open(path, encoding="utf-8") as fptr: 9 | return fptr.read() 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Define dynamic test fixtures.""" 2 | import asyncio 3 | from collections import deque 4 | import json 5 | from typing import List, Tuple 6 | from unittest.mock import AsyncMock, Mock, patch 7 | 8 | from aiohttp import ClientSession, ClientWebSocketResponse 9 | from aiohttp.http_websocket import WSMessage, WSMsgType 10 | import pytest 11 | 12 | from eufy_security_ws_python.client import WebsocketClient 13 | from eufy_security_ws_python.model.driver import Driver 14 | 15 | from .common import load_fixture 16 | 17 | TEST_URL = "ws://test.org:3000" 18 | 19 | # pylint: disable=protected-access, unused-argument 20 | 21 | 22 | def create_ws_message(result): 23 | """Return a mock WSMessage.""" 24 | message = Mock(spec_set=WSMessage) 25 | message.type = WSMsgType.TEXT 26 | message.data = json.dumps(result) 27 | message.json.return_value = result 28 | return message 29 | 30 | 31 | @pytest.fixture(name="client") 32 | async def client_fixture(client_session, loop, url, ws_client): 33 | """Return a client with a mock websocket transport. 34 | 35 | This fixture needs to be a coroutine function to get an event loop 36 | when creating the client. 37 | """ 38 | client = WebsocketClient(url, client_session) 39 | client._client = ws_client 40 | return client 41 | 42 | 43 | @pytest.fixture(name="client_session") 44 | def client_session_fixture(ws_client): 45 | """Mock an aiohttp client session.""" 46 | client_session = AsyncMock(spec_set=ClientSession) 47 | client_session.ws_connect.side_effect = AsyncMock(return_value=ws_client) 48 | return client_session 49 | 50 | 51 | @pytest.fixture(name="controller_state", scope="session") 52 | def controller_state_fixture(): 53 | """Load the controller state fixture data.""" 54 | return json.loads(load_fixture("controller_state.json")) 55 | 56 | 57 | @pytest.fixture(name="driver") 58 | def driver_fixture(client, result): 59 | """Return a driver instance with a supporting client.""" 60 | return Driver(client, result) 61 | 62 | 63 | @pytest.fixture(name="driver_ready") 64 | async def driver_ready_fixture(loop): 65 | """Return an asyncio.Event for driver ready.""" 66 | return asyncio.Event() 67 | 68 | 69 | @pytest.fixture(name="messages") 70 | def messages_fixture(): 71 | """Return a message buffer for the WS client.""" 72 | return deque() 73 | 74 | 75 | @pytest.fixture(name="mock_command") 76 | def mock_command_fixture(ws_client, client, uuid4): 77 | """Mock a command and response.""" 78 | mock_responses: List[Tuple[dict, dict, bool]] = [] 79 | ack_commands: List[dict] = [] 80 | 81 | def apply_mock_command( 82 | match_command: dict, response: dict, success: bool = True 83 | ) -> List[dict]: 84 | """Apply the mock command and response return value to the transport. 85 | 86 | Return the list with correctly acknowledged commands. 87 | """ 88 | mock_responses.append((match_command, response, success)) 89 | return ack_commands 90 | 91 | async def set_response(message): 92 | """Check the message and set the mocked response if a command matches.""" 93 | for match_command, response, success in mock_responses: 94 | if all(message[key] == value for key, value in match_command.items()): 95 | ack_commands.append(message) 96 | received_message = { 97 | "type": "result", 98 | "messageId": uuid4, 99 | "success": success, 100 | } 101 | if success: 102 | received_message["result"] = response 103 | else: 104 | received_message.update(response) 105 | client._parse_response_payload(received_message) 106 | return 107 | 108 | raise RuntimeError("Command not mocked!") 109 | 110 | ws_client.send_json.side_effect = set_response 111 | 112 | return apply_mock_command 113 | 114 | 115 | @pytest.fixture(name="uuid4") 116 | def mock_uuid4_fixture(): 117 | """Return a mocked websocket UUID-based message ID.""" 118 | uuid4_hex = "1234" 119 | with patch("uuid.uuid4") as uuid4: 120 | uuid4.return_value.hex = uuid4_hex 121 | yield uuid4_hex 122 | 123 | 124 | @pytest.fixture(name="result") 125 | def result_fixture(controller_state, uuid4): 126 | """Return a server result message.""" 127 | return { 128 | "messageId": uuid4, 129 | "result": {"state": controller_state}, 130 | "success": True, 131 | "type": "result", 132 | } 133 | 134 | 135 | @pytest.fixture(name="set_api_schema_data") 136 | def set_api_schema_data_fixture(): 137 | """Return a payload with API schema data.""" 138 | return { 139 | "messageId": "set_api_schema", 140 | "result": {}, 141 | "success": True, 142 | "type": "result", 143 | } 144 | 145 | 146 | @pytest.fixture(name="url") 147 | def url_fixture(): 148 | """Return a test url.""" 149 | return TEST_URL 150 | 151 | 152 | @pytest.fixture(name="version_data") 153 | def version_data_fixture(loop): 154 | """Return a payload with version data.""" 155 | return { 156 | "driverVersion": "0.8.2", 157 | "maxSchemaVersion": 1, 158 | "minSchemaVersion": 0, 159 | "serverVersion": "0.1.2", 160 | "type": "version", 161 | } 162 | 163 | 164 | @pytest.fixture(name="ws_client") 165 | async def ws_client_fixture( 166 | loop, messages, result, set_api_schema_data, version_data, 167 | ): 168 | """Mock a websocket client. 169 | 170 | This fixture only allows a single message to be received. 171 | """ 172 | ws_client = AsyncMock(spec_set=ClientWebSocketResponse, closed=False) 173 | ws_client.receive_json.side_effect = (version_data, set_api_schema_data, result) 174 | for data in (version_data, set_api_schema_data, result): 175 | messages.append(create_ws_message(data)) 176 | 177 | async def receive(): 178 | """Return a websocket message.""" 179 | await asyncio.sleep(0) 180 | 181 | message = messages.popleft() 182 | if not messages: 183 | ws_client.closed = True 184 | 185 | return message 186 | 187 | ws_client.receive.side_effect = receive 188 | 189 | async def close_client(msg): 190 | """Close the client.""" 191 | if msg["command"] in ("set_api_schema", "start_listening"): 192 | return 193 | 194 | await asyncio.sleep(0) 195 | ws_client.closed = True 196 | 197 | ws_client.send_json.side_effect = close_client 198 | 199 | async def reset_close(): 200 | """Reset the websocket client close method.""" 201 | ws_client.closed = True 202 | 203 | ws_client.close.side_effect = reset_close 204 | 205 | return ws_client 206 | 207 | 208 | @pytest.fixture(name="ws_message") 209 | def ws_message_fixture(result): 210 | """Return a mock WSMessage.""" 211 | return create_ws_message(result) 212 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | """Define test fixtures.""" 2 | -------------------------------------------------------------------------------- /tests/fixtures/controller_state.json: -------------------------------------------------------------------------------- 1 | { 2 | "driver": { 3 | "version": "0.8.2", 4 | "connected": true, 5 | "pushConnected": true 6 | }, 7 | "stations": [ 8 | { 9 | "name": "Home", 10 | "model": "T8001", 11 | "serialNumber": "ABCDEF1234567890", 12 | "hardwareVersion": "P1", 13 | "softwareVersion": "2.1.6.9", 14 | "lanIpAddress": "192.16.10.101", 15 | "macAddress": "AB:CD:EF:12:34:56", 16 | "currentMode": 1, 17 | "guardMode": 2, 18 | "connected": true 19 | } 20 | ], 21 | "devices": [ 22 | { 23 | "name": "Driveway", 24 | "model": "T8111", 25 | "serialNumber": "AABBCCDDEEFF1234", 26 | "hardwareVersion": "HAIYI-IMX323", 27 | "softwareVersion": "1.9.3", 28 | "stationSerialNumber": "ABCDEF1234567890", 29 | "enabled": true, 30 | "motionDetected": false, 31 | "personDetected": false, 32 | "personName": "", 33 | "autoNightvision": true, 34 | "motionDetection": true, 35 | "watermark": 0, 36 | "pictureUrl": "https://images.com/image" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tests/fixtures/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "event", 3 | "event": { 4 | "source": "station", 5 | "event": "guard mode changed", 6 | "serialNumber": "ABCDEF1234567890", 7 | "guardMode": 2, 8 | "currentMode": 1 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/model/__init__.py: -------------------------------------------------------------------------------- 1 | """Define model tests.""" 2 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | """Define tests for the client.""" 2 | import asyncio 3 | from unittest.mock import Mock 4 | 5 | from aiohttp.client_exceptions import ClientError, WSServerHandshakeError 6 | from aiohttp.client_reqrep import ClientResponse, RequestInfo 7 | from aiohttp.http_websocket import WSMsgType 8 | import pytest 9 | 10 | from eufy_security_ws_python.client import WebsocketClient 11 | from eufy_security_ws_python.const import MAX_SERVER_SCHEMA_VERSION 12 | from eufy_security_ws_python.errors import ( 13 | CannotConnectError, 14 | ConnectionFailed, 15 | FailedCommand, 16 | InvalidMessage, 17 | InvalidServerVersion, 18 | NotConnectedError, 19 | ) 20 | 21 | pytestmark = pytest.mark.asyncio 22 | 23 | # pylint: disable=too-many-arguments 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "error", 28 | [ClientError, WSServerHandshakeError(Mock(RequestInfo), (Mock(ClientResponse),))], 29 | ) 30 | async def test_cannot_connect(client_session, error, url): 31 | """Test cannot connect.""" 32 | client_session.ws_connect.side_effect = error 33 | client = WebsocketClient(url, client_session) 34 | 35 | with pytest.raises(CannotConnectError): 36 | await client.async_connect() 37 | 38 | assert not client.connected 39 | 40 | 41 | async def test_command_error_handling(client, mock_command): 42 | """Test error handling.""" 43 | mock_command( 44 | {"command": "some_command"}, {"errorCode": "unknown_command",}, False, 45 | ) 46 | 47 | with pytest.raises(FailedCommand) as raised: 48 | await client.async_send_command({"command": "some_command"}) 49 | 50 | assert raised.value.error_code == "unknown_command" 51 | 52 | 53 | async def test_connect_disconnect(client_session, url): 54 | """Test client connect and disconnect.""" 55 | async with WebsocketClient(url, client_session) as client: 56 | assert client.connected 57 | 58 | assert not client.connected 59 | 60 | 61 | async def test_listen(client_session, driver_ready, url): 62 | """Test client listen.""" 63 | client = WebsocketClient(url, client_session) 64 | 65 | assert not client.driver 66 | 67 | await client.async_connect() 68 | 69 | assert client.connected 70 | 71 | asyncio.create_task(client.async_listen(driver_ready)) 72 | await driver_ready.wait() 73 | assert client.driver 74 | 75 | await client.async_disconnect() 76 | assert not client.connected 77 | 78 | 79 | async def test_listen_client_error( 80 | client_session, driver_ready, messages, url, ws_client, ws_message 81 | ): 82 | """Test websocket error on listen.""" 83 | client = WebsocketClient(url, client_session) 84 | await client.async_connect() 85 | assert client.connected 86 | 87 | messages.append(ws_message) 88 | 89 | ws_client.receive.side_effect = asyncio.CancelledError() 90 | 91 | # This should break out of the listen loop before any message is received: 92 | with pytest.raises(asyncio.CancelledError): 93 | await client.async_listen(driver_ready) 94 | 95 | assert not ws_message.json.called 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "message_type", [WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING] 100 | ) 101 | async def test_listen_disconnect_message_types( 102 | client_session, driver_ready, message_type, messages, url, ws_client, ws_message 103 | ): 104 | """Test different websocket message types that stop listen.""" 105 | async with WebsocketClient(url, client_session) as client: 106 | assert client.connected 107 | ws_message.type = message_type 108 | messages.append(ws_message) 109 | 110 | # This should break out of the listen loop before handling the received message; 111 | # otherwise there will be an error: 112 | await client.async_listen(driver_ready) 113 | 114 | # Assert that we received a message: 115 | ws_client.receive.assert_awaited() 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "message_type, exception", 120 | [(WSMsgType.ERROR, ConnectionFailed), (WSMsgType.BINARY, InvalidMessage),], 121 | ) 122 | async def test_listen_error_message_types( 123 | client_session, driver_ready, exception, message_type, messages, url, ws_message 124 | ): 125 | """Test different websocket message types that should raise on listen.""" 126 | client = WebsocketClient(url, client_session) 127 | await client.async_connect() 128 | assert client.connected 129 | 130 | ws_message.type = message_type 131 | messages.append(ws_message) 132 | 133 | with pytest.raises(exception): 134 | await client.async_listen(driver_ready) 135 | 136 | 137 | async def test_listen_event( 138 | client_session, url, ws_client, messages, ws_message, result, driver_ready 139 | ): 140 | """Test receiving event result type on listen.""" 141 | client = WebsocketClient(url, client_session) 142 | await client.async_connect() 143 | 144 | assert client.connected 145 | 146 | result["type"] = "event" 147 | result["event"] = { 148 | "source": "station", 149 | "event": "property changed", 150 | "serialNumber": "ABCDEF1234567890", 151 | "name": "currentMode", 152 | "value": 63, 153 | "timestamp": 1622949673501, 154 | } 155 | messages.append(ws_message) 156 | 157 | await client.async_listen(driver_ready) 158 | ws_client.receive.assert_awaited() 159 | 160 | 161 | async def test_listen_invalid_message_data( 162 | client_session, driver_ready, messages, url, ws_message 163 | ): 164 | """Test websocket message data that should raise on listen.""" 165 | client = WebsocketClient(url, client_session) 166 | await client.async_connect() 167 | assert client.connected 168 | 169 | ws_message.json.side_effect = ValueError("Boom") 170 | messages.append(ws_message) 171 | 172 | with pytest.raises(InvalidMessage): 173 | await client.async_listen(driver_ready) 174 | 175 | 176 | async def test_listen_not_success(client_session, driver_ready, result, url): 177 | """Test receive result message with success False on listen.""" 178 | result["success"] = False 179 | result["errorCode"] = "error_code" 180 | 181 | client = WebsocketClient(url, client_session) 182 | await client.async_connect() 183 | 184 | with pytest.raises(FailedCommand): 185 | await client.async_listen(driver_ready) 186 | 187 | assert not client.connected 188 | 189 | 190 | async def test_listen_unknown_result_type( 191 | client_session, url, ws_client, result, driver_ready, driver 192 | ): 193 | """Test websocket message with unknown type on listen.""" 194 | client = WebsocketClient(url, client_session) 195 | await client.async_connect() 196 | 197 | assert client.connected 198 | 199 | # Make sure there's a driver so we can test an unknown event. 200 | client.driver = driver 201 | result["type"] = "unknown" 202 | 203 | # Receiving an unknown message type should not error. 204 | await client.async_listen(driver_ready) 205 | 206 | ws_client.receive.assert_awaited() 207 | 208 | 209 | async def test_listen_without_connect(client_session, driver_ready, url): 210 | """Test listen without first being connected.""" 211 | client = WebsocketClient(url, client_session) 212 | assert not client.connected 213 | 214 | with pytest.raises(NotConnectedError): 215 | await client.async_listen(driver_ready) 216 | 217 | 218 | async def test_max_schema_version(client_session, url, version_data): 219 | """Test client connect with an invalid schema version.""" 220 | version_data["maxSchemaVersion"] = 0 221 | client = WebsocketClient(url, client_session) 222 | 223 | with pytest.raises(InvalidServerVersion): 224 | await client.async_connect() 225 | 226 | assert not client.connected 227 | 228 | 229 | async def test_min_schema_version(client_session, url, version_data): 230 | """Test client connect with invalid schema version.""" 231 | version_data["minSchemaVersion"] = 100 232 | client = WebsocketClient(url, client_session) 233 | 234 | with pytest.raises(InvalidServerVersion): 235 | await client.async_connect() 236 | 237 | assert not client.connected 238 | 239 | 240 | async def test_send_json_when_disconnected(client_session, url): 241 | """Test sending a JSON message when disconnected.""" 242 | client = WebsocketClient(url, client_session) 243 | 244 | assert not client.connected 245 | 246 | with pytest.raises(NotConnectedError): 247 | await client.async_send_command({"test": None}) 248 | 249 | 250 | async def test_send_unsupported_command( 251 | client_session, driver, driver_ready, url, ws_client 252 | ): 253 | """Test sending unsupported command.""" 254 | client = WebsocketClient(url, client_session) 255 | await client.async_connect() 256 | assert client.connected 257 | client.driver = driver 258 | await client.async_listen(driver_ready) 259 | ws_client.receive.assert_awaited() 260 | 261 | # Test schema version is at server maximum: 262 | if client.version.max_schema_version < MAX_SERVER_SCHEMA_VERSION: 263 | assert client.schema_version == client.version.max_schema_version 264 | 265 | # Ensure a command with the current schema version doesn't fail: 266 | with pytest.raises(NotConnectedError): 267 | await client.async_send_command( 268 | {"command": "test"}, require_schema=client.schema_version 269 | ) 270 | # send command of unsupported schema version should fail 271 | with pytest.raises(InvalidServerVersion): 272 | await client.async_send_command( 273 | {"command": "test"}, require_schema=client.schema_version + 2 274 | ) 275 | with pytest.raises(InvalidServerVersion): 276 | await client.async_send_command_no_wait( 277 | {"command": "test"}, require_schema=client.schema_version + 2 278 | ) 279 | 280 | 281 | async def test_set_api_schema_not_success( 282 | client_session, driver_ready, set_api_schema_data, url 283 | ): 284 | """Test receive result message with success False on listen.""" 285 | set_api_schema_data["success"] = False 286 | set_api_schema_data["errorCode"] = "error_code" 287 | 288 | client = WebsocketClient(url, client_session) 289 | await client.async_connect() 290 | 291 | with pytest.raises(FailedCommand): 292 | await client.async_listen(driver_ready) 293 | 294 | assert not client.connected 295 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | """Define tests for events.""" 2 | from eufy_security_ws_python import event 3 | 4 | 5 | def test_once(): 6 | """Test once listens to event once.""" 7 | mock = event.EventBase() 8 | calls = [] 9 | mock.once("test-event", calls.append) 10 | mock.emit("test-event", 1) 11 | mock.emit("test-event", 2) 12 | assert len(calls) == 1 13 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | """Test the server version helper.""" 2 | from unittest.mock import call 3 | 4 | import pytest 5 | 6 | from eufy_security_ws_python.version import async_get_server_version 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_async_get_server_version(client_session, ws_client, url, version_data): 11 | """Test the get server version helper.""" 12 | ws_client.receive_json.return_value = version_data 13 | 14 | version_info = await async_get_server_version(url, client_session) 15 | 16 | assert client_session.ws_connect.called 17 | assert client_session.ws_connect.call_args == call(url) 18 | assert version_info.driver_version == version_data["driverVersion"] 19 | assert version_info.server_version == version_data["serverVersion"] 20 | assert version_info.min_schema_version == version_data["minSchemaVersion"] 21 | assert version_info.max_schema_version == version_data["maxSchemaVersion"] 22 | assert ws_client.close.called 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_missing_server_schema_version( 27 | client_session, ws_client, url, version_data 28 | ): 29 | """Test missing schema version processed as schema version 0.""" 30 | del version_data["minSchemaVersion"] 31 | del version_data["maxSchemaVersion"] 32 | ws_client.receive_json.return_value = version_data 33 | version_info = await async_get_server_version(url, client_session) 34 | assert version_info.min_schema_version == 0 35 | assert version_info.max_schema_version == 0 36 | assert ws_client.close.called 37 | --------------------------------------------------------------------------------