diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 490adaef0..eb680ac2b 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -1,49 +1,43 @@ --- name: Setup Environment description: Install uv, configure the system python, and the package dependencies - inputs: uv-install-options: default: "" - uv-version: - default: 0.4.16 python-version: required: true cache-pre-commit: default: false cache-version: default: "v0.1" - runs: using: composite steps: - - name: Install uv - uses: astral-sh/setup-uv@v3 + - name: Setup uv + id: setup-uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: enable-cache: true + version-file: uv.lock + python-version: ${{ inputs.python-version }} - - name: "Setup python" - uses: "actions/setup-python@v5" - id: setup-python - with: - python-version: "${{ inputs.python-version }}" - allow-prereleases: true - - - name: "Install project" + - name: Install Project Dependencies + id: install-project-dependencies shell: bash run: | uv sync ${{ inputs.uv-install-options }} - - name: Read pre-commit version - if: inputs.cache-pre-commit == 'true' + - name: Read pre-commit Version id: pre-commit-version + if: inputs.cache-pre-commit == 'true' shell: bash run: >- echo "pre-commit-version=$(uv run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 + - name: pre-commit Cache + id: pre-commit-cache if: inputs.cache-pre-commit == 'true' - name: Pre-commit cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: ~/.cache/pre-commit/ - key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} + key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-uv.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c145cc1..7a54874d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,48 +1,57 @@ +--- name: CI on: push: - branches: ["master", "patch"] + branches: + - master + - patch pull_request: - branches: ["master", "patch"] - workflow_dispatch: # to allow manual re-runs + branches: + - master + - patch + - 'feat/**' + - 'fix/**' + - 'janitor/**' + workflow_dispatch: -env: - UV_VERSION: 0.4.16 +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - linting: - name: "Perform linting checks" + lint: + name: Perform Lint Checks runs-on: ubuntu-latest - strategy: matrix: - python-version: ["3.13"] - + python-version: [3.14] steps: - - name: "Checkout source files" - uses: "actions/checkout@v4" - - name: Setup environment + - name: Checkout Source Files + id: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Environment + id: setup-environment uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} cache-pre-commit: true - uv-version: ${{ env.UV_VERSION }} - uv-install-options: "--all-extras" + uv-install-options: --all-extras - - name: "Run pre-commit checks" + - name: Run pre-commit Checks + id: run-pre-commit + shell: bash run: | uv run pre-commit run --all-files --verbose - tests: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} - needs: linting + needs: lint runs-on: ${{ matrix.os }} - strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: [3.11, 3.12, 3.13, 3.14] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -50,19 +59,30 @@ jobs: extras: true - os: windows-latest extras: true - steps: - - uses: "actions/checkout@v4" - - name: Setup environment + - name: Checkout Source Files + id: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Environment + id: setup-environment uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} - uv-version: ${{ env.UV_VERSION }} uv-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} - - name: "Run tests (with coverage)" + + - name: Run PyTests with Code Coverage + id: run-pytests-with-code-coverage + shell: bash run: | - uv run pytest -n auto --cov kasa --cov-report xml - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v4" + if [[ "${{ runner.os }}" == "Windows" ]]; then + uv run pytest -n0 --cov kasa --cov-report xml + else + uv run pytest -n auto --cov kasa --cov-report xml + fi + + - name: Upload Code Coverage to Codecov + id: upload-code-coverage-to-codecov + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 29d533581..b0f1f963f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,17 +1,31 @@ -name: "CodeQL checks" +--- +name: CodeQL Checks on: push: - branches: [ "master", "patch" ] + branches: + - master + - patch pull_request: - branches: [ master, "patch" ] + branches: + - master + - patch + - 'feat/**' + - 'fix/**' + - 'janitor/**' schedule: - cron: '44 17 * * 3' +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze runs-on: ubuntu-latest + env: + CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true permissions: actions: read contents: read @@ -20,16 +34,19 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python' ] + language: [python] steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout Source Files + id: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + id: init-codeql + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + id: perform-codeql-analysis + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c1837f0c..c4dff1ae3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,33 +1,36 @@ -name: Publish packages +--- +name: Publish Packages + on: release: types: [published] env: - UV_VERSION: 0.4.16 - PYTHON_VERSION: 3.12 + PYTHON_VERSION: 3.13 jobs: build-n-publish: - name: Build release packages + name: Build Release Packages runs-on: ubuntu-latest - permissions: # for trusted publishing + permissions: id-token: write - steps: - - name: Checkout source files - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 + - name: Checkout Source Files + id: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Setup python - uses: actions/setup-python@v4 + - name: Setup uv + id: setup-uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: + version-file: uv.lock python-version: ${{ env.PYTHON_VERSION }} - - name: Build a binary wheel and a source tarball + - name: Build Packages + id: build-packages + shell: bash run: uv build - - name: Publish release on pypi - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Publish Release on PyPI + id: publish-release-on-pypi + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 294b4e1f8..2b973d687 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,6 +1,6 @@ +--- name: Stale -# yamllint disable-line rule:truthy on: schedule: - cron: "0 0 * * *" @@ -11,8 +11,9 @@ jobs: if: github.repository_owner == 'python-kasa' runs-on: ubuntu-latest steps: - - name: Stale issues and prs - uses: actions/stale@v9.0.0 + - name: Stale Issues and PRs Policy + id: stale-issues-and-prs-policy + uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 with: repo-token: ${{ github.token }} days-before-stale: 90 @@ -43,9 +44,9 @@ jobs: Thank you for your contributions. - - - name: Needs-more-information and waiting-for-reporter stale issues policy - uses: actions/stale@v9.0.0 + - name: needs-more-information and waiting-for-reporter Stale Issues Policy + id: specific-stale-issues-policy + uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 with: repo-token: ${{ github.token }} only-labels: "needs-more-information,waiting-for-reporter" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index adcad8e4e..cd09ba4c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,13 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.4.16 + rev: 0.9.5 hooks: # Update the uv lockfile - id: uv-lock - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -16,16 +16,20 @@ repos: - id: check-yaml - id: debug-statements - id: check-ast + - id: pretty-format-json + args: + - "--autofix" + - "--indent=4" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.14.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/PyCQA/doc8 - rev: 'v1.1.1' + rev: 'v2.0.0' hooks: - id: doc8 additional_dependencies: [tomli] diff --git a/.readthedocs.yml b/.readthedocs.yml index 1d01cf18f..9c3144b99 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,17 +2,17 @@ version: 2 formats: all +sphinx: + configuration: docs/source/conf.py + + build: os: ubuntu-22.04 tools: python: "3" jobs: + install: + - pip install --upgrade pip + - pip install --group 'docs' pre_build: - python -m sphinx -b linkcheck docs/source/ $READTHEDOCS_OUTPUT/linkcheck - -python: - install: - - method: pip - path: . - extra_requirements: - - docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef0873f0..5513b9dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,265 @@ # Changelog -## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) +## [0.10.2](https://github.com/python-kasa/python-kasa/tree/0.10.2) (2025-02-12) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.1...0.10.2) + +**Release summary:** + +- Bugfix for [#1499](https://github.com/python-kasa/python-kasa/issues/1499). +- Support for L530B and C110 devices. + +**Fixed bugs:** + +- H100 - Raised error: not enough values to unpack \(expected 2, got 1\) [\#1499](https://github.com/python-kasa/python-kasa/issues/1499) +- Do not crash on missing build number in fw version [\#1500](https://github.com/python-kasa/python-kasa/pull/1500) (@rytilahti) + +**Added support for devices:** + +- Add C110\(EU\) 2.0 1.4.3 fixture [\#1503](https://github.com/python-kasa/python-kasa/pull/1503) (@sdb9696) +- Add L530B\(EU\) 3.0 1.1.9 fixture [\#1502](https://github.com/python-kasa/python-kasa/pull/1502) (@sdb9696) + +**Project maintenance:** + +- Add fixtures for new versions of H100, P110, and T100 devices [\#1501](https://github.com/python-kasa/python-kasa/pull/1501) (@LXGaming) +- Add L530E\(TW\) 2.0 1.1.1 fixture [\#1497](https://github.com/python-kasa/python-kasa/pull/1497) (@bluehomewu) + +## [0.10.1](https://github.com/python-kasa/python-kasa/tree/0.10.1) (2025-02-02) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.10.0...0.10.1) + +**Release summary:** + +Small patch release for bugfixes + +**Implemented enhancements:** + +- dustbin\_mode: add 'off' mode for cleaner downstream impl [\#1488](https://github.com/python-kasa/python-kasa/pull/1488) (@rytilahti) +- Add Dimmer Configuration Support [\#1484](https://github.com/python-kasa/python-kasa/pull/1484) (@ryenitcher) + +**Fixed bugs:** + +- Do not return empty string for custom light effect name [\#1491](https://github.com/python-kasa/python-kasa/pull/1491) (@sdb9696) +- Add FeatureAttributes to smartcam Alarm [\#1489](https://github.com/python-kasa/python-kasa/pull/1489) (@sdb9696) + +**Project maintenance:** + +- Add module.device to the public api [\#1478](https://github.com/python-kasa/python-kasa/pull/1478) (@sdb9696) + +## [0.10.0](https://github.com/python-kasa/python-kasa/tree/0.10.0) (2025-01-26) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.1...0.10.0) + +**Release summary:** + +This release brings support for many new devices, including completely new device types: + +- Support for Tapo robot vacuums. Special thanks to @steveredden, @MAXIGAMESSUPPER, and veep60 for helping to get this implemented! +- Support for hub attached cameras and doorbells (H200) +- Improved support for hubs (including pairing & better chime controls) +- Support for many new camera and doorbell device models, including C220, C720, D100C, D130, and D230 + +Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski, @Obbay2, @andrewome, @ryenitcher and @etmmvdp! + +**Breaking changes:** + +- `uses_http` is now a readonly property of device config. Consumers that relied on `uses_http` to be persisted with `DeviceConfig.to_dict()` will need to store the value separately. +- `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperature_range`, and `has_effects` attributes from the `Light` module are deprecated, consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them. +- `alarm_volume` on the `smart.Alarm` module is changed from `str` to `int` + +**Breaking changes:** + +- Make uses\_http a readonly property of device config [\#1449](https://github.com/python-kasa/python-kasa/pull/1449) (@sdb9696) +- Allow passing alarm parameter overrides [\#1340](https://github.com/python-kasa/python-kasa/pull/1340) (@rytilahti) +- Deprecate legacy light module is\_capability checks [\#1297](https://github.com/python-kasa/python-kasa/pull/1297) (@sdb9696) + +**Implemented enhancements:** + +- Expose more battery sensors for D230 [\#1451](https://github.com/python-kasa/python-kasa/issues/1451) +- dumping HTTP POST Body for Tapo Vacuum \(RV30 Plus\) [\#937](https://github.com/python-kasa/python-kasa/issues/937) +- Add smartcam pet detection toggle module [\#1465](https://github.com/python-kasa/python-kasa/pull/1465) (@DawidPietrykowski) +- Add childlock module for vacuums [\#1461](https://github.com/python-kasa/python-kasa/pull/1461) (@rytilahti) +- Add ultra mode \(fanspeed = 5\) for vacuums [\#1459](https://github.com/python-kasa/python-kasa/pull/1459) (@rytilahti) +- Add setting to change carpet clean mode [\#1458](https://github.com/python-kasa/python-kasa/pull/1458) (@rytilahti) +- Add setting to change clean count [\#1457](https://github.com/python-kasa/python-kasa/pull/1457) (@rytilahti) +- Add mop module [\#1456](https://github.com/python-kasa/python-kasa/pull/1456) (@rytilahti) +- Enable dynamic hub child creation and deletion on update [\#1454](https://github.com/python-kasa/python-kasa/pull/1454) (@sdb9696) +- Expose current cleaning information [\#1453](https://github.com/python-kasa/python-kasa/pull/1453) (@rytilahti) +- Add battery module to smartcam devices [\#1452](https://github.com/python-kasa/python-kasa/pull/1452) (@sdb9696) +- Allow update of camera modules after setting values [\#1450](https://github.com/python-kasa/python-kasa/pull/1450) (@sdb9696) +- Update hub children on first update and delay subsequent updates [\#1438](https://github.com/python-kasa/python-kasa/pull/1438) (@sdb9696) +- Implement vacuum dustbin module \(dust\_bucket\) [\#1423](https://github.com/python-kasa/python-kasa/pull/1423) (@rytilahti) +- Add smartcam child device support for smartcam hubs [\#1413](https://github.com/python-kasa/python-kasa/pull/1413) (@sdb9696) +- Add vacuum speaker controls [\#1332](https://github.com/python-kasa/python-kasa/pull/1332) (@rytilahti) +- Add consumables module for vacuums [\#1327](https://github.com/python-kasa/python-kasa/pull/1327) (@rytilahti) +- Add ADC Value to PIR Enabled Switches [\#1263](https://github.com/python-kasa/python-kasa/pull/1263) (@ryenitcher) +- Add support for cleaning records [\#945](https://github.com/python-kasa/python-kasa/pull/945) (@rytilahti) +- Initial support for vacuums \(clean module\) [\#944](https://github.com/python-kasa/python-kasa/pull/944) (@rytilahti) +- Add support for pairing devices with hubs [\#859](https://github.com/python-kasa/python-kasa/pull/859) (@rytilahti) +- Add common alarm interface [\#1479](https://github.com/python-kasa/python-kasa/pull/1479) (@sdb9696) +- Add common childsetup interface [\#1470](https://github.com/python-kasa/python-kasa/pull/1470) (@sdb9696) +- Add childsetup module to smartcam hubs [\#1469](https://github.com/python-kasa/python-kasa/pull/1469) (@sdb9696) +- Only log one warning per unknown clean error code and status [\#1462](https://github.com/python-kasa/python-kasa/pull/1462) (@rytilahti) +- Add support for doorbells and chimes [\#1435](https://github.com/python-kasa/python-kasa/pull/1435) (@steveredden) +- Allow https for klaptransport [\#1415](https://github.com/python-kasa/python-kasa/pull/1415) (@rytilahti) +- Add powerprotection module [\#1337](https://github.com/python-kasa/python-kasa/pull/1337) (@rytilahti) + +**Fixed bugs:** + +- TP-Link HS300 Wi-Fi Power-Strip - "Parent On/Off" not functioning. [\#637](https://github.com/python-kasa/python-kasa/issues/637) +- Report 0 for instead of None for zero current and voltage [\#1483](https://github.com/python-kasa/python-kasa/pull/1483) (@ryenitcher) +- ssltransport: use debug logger for sending requests [\#1443](https://github.com/python-kasa/python-kasa/pull/1443) (@rytilahti) +- Fix discover cli command with host [\#1437](https://github.com/python-kasa/python-kasa/pull/1437) (@sdb9696) +- Fallback to is\_low for batterysensor's battery\_low [\#1420](https://github.com/python-kasa/python-kasa/pull/1420) (@rytilahti) +- Convert carpet\_clean\_mode to carpet\_boost switch [\#1486](https://github.com/python-kasa/python-kasa/pull/1486) (@rytilahti) +- Change category for empty dustbin feature from Primary to Config [\#1485](https://github.com/python-kasa/python-kasa/pull/1485) (@rytilahti) +- Disable iot camera creation until more complete [\#1480](https://github.com/python-kasa/python-kasa/pull/1480) (@sdb9696) +- Add error code 7 for clean module [\#1474](https://github.com/python-kasa/python-kasa/pull/1474) (@rytilahti) +- Fix iot strip turn on and off from parent [\#639](https://github.com/python-kasa/python-kasa/pull/639) (@Obbay2) + +**Added support for devices:** + +- Add C220\(EU\) 1.0 1.2.2 camera fixture [\#1466](https://github.com/python-kasa/python-kasa/pull/1466) (@DawidPietrykowski) +- Add D230\(EU\) 1.20 1.1.19 fixture [\#1448](https://github.com/python-kasa/python-kasa/pull/1448) (@sdb9696) +- Add fixture for C720 camera [\#1433](https://github.com/python-kasa/python-kasa/pull/1433) (@steveredden) +- Add D130\(US\) 1.0 1.1.9 fixture [\#1476](https://github.com/python-kasa/python-kasa/pull/1476) (@sdb9696) +- Add D100C\(US\) 1.0 1.1.3 fixture [\#1475](https://github.com/python-kasa/python-kasa/pull/1475) (@sdb9696) + +**Project maintenance:** + +- Enable CI workflow on PRs to feat/ fix/ and janitor/ [\#1471](https://github.com/python-kasa/python-kasa/pull/1471) (@sdb9696) +- Add commit-hook to prettify JSON files [\#1455](https://github.com/python-kasa/python-kasa/pull/1455) (@rytilahti) +- Add required sphinx.configuration [\#1446](https://github.com/python-kasa/python-kasa/pull/1446) (@rytilahti) +- Add more redactors for smartcams [\#1439](https://github.com/python-kasa/python-kasa/pull/1439) (@sdb9696) +- Add KS230\(US\) 2.0 1.0.11 IOT Fixture [\#1430](https://github.com/python-kasa/python-kasa/pull/1430) (@ZeliardM) +- Update ruff to 0.9 [\#1482](https://github.com/python-kasa/python-kasa/pull/1482) (@sdb9696) +- Cancel in progress CI workflows after new pushes [\#1481](https://github.com/python-kasa/python-kasa/pull/1481) (@sdb9696) +- Update test framework to support smartcam device discovery. [\#1477](https://github.com/python-kasa/python-kasa/pull/1477) (@sdb9696) +- Add tests for dump\_devinfo parent/child smartcam fixture generation [\#1428](https://github.com/python-kasa/python-kasa/pull/1428) (@sdb9696) +- Raise errors on single smartcam child requests [\#1427](https://github.com/python-kasa/python-kasa/pull/1427) (@sdb9696) + +## [0.9.1](https://github.com/python-kasa/python-kasa/tree/0.9.1) (2025-01-06) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.9.0...0.9.1) + +**Release summary:** + +- Support for hub-attached wall switches S210 and S220 +- Support for older firmware on Tapo cameras +- Bugfixes and improvements + +**Implemented enhancements:** + +- Add support for Tapo hub-attached switch devices [\#1421](https://github.com/python-kasa/python-kasa/pull/1421) (@sdb9696) +- Use repr\(\) for enum values in Feature.\_\_repr\_\_ [\#1414](https://github.com/python-kasa/python-kasa/pull/1414) (@rytilahti) +- Update SslAesTransport for older firmware versions [\#1362](https://github.com/python-kasa/python-kasa/pull/1362) (@sdb9696) -This patch release fixes some issues with newly supported smartcam devices. +**Fixed bugs:** + +- T310 not detected with H200 Hub [\#1409](https://github.com/python-kasa/python-kasa/issues/1409) +- Fix incorrect obd src echo [\#1412](https://github.com/python-kasa/python-kasa/pull/1412) (@rytilahti) +- Backoff after xor timeout and improve error reporting [\#1424](https://github.com/python-kasa/python-kasa/pull/1424) (@bdraco) +- Handle smartcam partial list responses [\#1411](https://github.com/python-kasa/python-kasa/pull/1411) (@sdb9696) + +**Added support for devices:** + +- Add S220 fixture [\#1419](https://github.com/python-kasa/python-kasa/pull/1419) (@rytilahti) +- Add S210 fixture [\#1418](https://github.com/python-kasa/python-kasa/pull/1418) (@rytilahti) + +**Documentation updates:** + +- Improve exception messages on credential mismatches [\#1417](https://github.com/python-kasa/python-kasa/pull/1417) (@rytilahti) + +**Project maintenance:** + +- Add HS210\(US\) 3.0 1.0.10 IOT Fixture [\#1405](https://github.com/python-kasa/python-kasa/pull/1405) (@ZeliardM) +- Add C210 2.0 1.3.11 fixture [\#1406](https://github.com/python-kasa/python-kasa/pull/1406) (@sdb9696) +- Change smartcam detection features to category config [\#1402](https://github.com/python-kasa/python-kasa/pull/1402) (@sdb9696) + +## [0.9.0](https://github.com/python-kasa/python-kasa/tree/0.9.0) (2024-12-21) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.1...0.9.0) + +**Release highlights:** + +- Improvements to Tapo camera support: + - C100, C225, C325WB, C520WS and TC70 now supported. + - Support for motion, person, tamper, and baby cry detection. +- Initial support for Tapo robovacs. +- API extended with `FeatureAttributes` for consumers to test for [supported features](https://python-kasa.readthedocs.io/en/stable/topics.html#modules-and-features). +- Experimental support for Kasa cameras[^1] + +[^1]: Currently limited to devices not yet provisioned via the Tapo app - Many thanks to @puxtril! + +**Breaking changes:** + +- Use DeviceInfo consistently across devices [\#1338](https://github.com/python-kasa/python-kasa/pull/1338) (@sdb9696) + +**Implemented enhancements:** + +- Add bare-bones matter modules to smart and smartcam devices [\#1371](https://github.com/python-kasa/python-kasa/pull/1371) (@sdb9696) +- Add bare bones homekit modules smart and smartcam devices [\#1370](https://github.com/python-kasa/python-kasa/pull/1370) (@sdb9696) +- cli: print model, https, and lv for discover list [\#1339](https://github.com/python-kasa/python-kasa/pull/1339) (@rytilahti) +- Add LinkieTransportV2 and basic IOT.IPCAMERA support [\#1270](https://github.com/python-kasa/python-kasa/pull/1270) (@Puxtril) +- Add ssltransport for robovacs [\#943](https://github.com/python-kasa/python-kasa/pull/943) (@rytilahti) +- Add rssi and signal\_level to smartcam [\#1392](https://github.com/python-kasa/python-kasa/pull/1392) (@sdb9696) +- Add smartcam detection modules [\#1389](https://github.com/python-kasa/python-kasa/pull/1389) (@sdb9696) +- Return raw discovery result in cli discover raw [\#1342](https://github.com/python-kasa/python-kasa/pull/1342) (@sdb9696) +- Improve overheat reporting [\#1335](https://github.com/python-kasa/python-kasa/pull/1335) (@rytilahti) +- Provide alternative camera urls [\#1316](https://github.com/python-kasa/python-kasa/pull/1316) (@sdb9696) + +**Fixed bugs:** + +- Tapo H200 Hub does not work with python-kasa [\#1149](https://github.com/python-kasa/python-kasa/issues/1149) +- Fix lens mask required component and state [\#1386](https://github.com/python-kasa/python-kasa/pull/1386) (@sdb9696) +- Add LensMask module to smartcam [\#1385](https://github.com/python-kasa/python-kasa/pull/1385) (@sdb9696) +- Treat smartcam 500 errors after handshake as retryable [\#1395](https://github.com/python-kasa/python-kasa/pull/1395) (@sdb9696) +- Do not error when accessing smart device\_type before update [\#1319](https://github.com/python-kasa/python-kasa/pull/1319) (@sdb9696) +- Fallback to other module data on get\_energy\_usage errors [\#1245](https://github.com/python-kasa/python-kasa/pull/1245) (@rytilahti) + +**Added support for devices:** + +- Add P210M\(US\) 1.0 1.0.3 fixture [\#1399](https://github.com/python-kasa/python-kasa/pull/1399) (@sdb9696) +- Add C225\(US\) 2.0 1.0.11 fixture [\#1398](https://github.com/python-kasa/python-kasa/pull/1398) (@sdb9696) +- Add P306\(US\) 1.0 1.1.2 fixture [\#1396](https://github.com/python-kasa/python-kasa/pull/1396) (@nakanaela) +- Add TC70 3.0 1.3.11 fixture [\#1390](https://github.com/python-kasa/python-kasa/pull/1390) (@sdb9696) +- Add KS200 \(US\) IOT Fixture and P115 \(US\) Smart Fixture [\#1355](https://github.com/python-kasa/python-kasa/pull/1355) (@ZeliardM) +- Add C520WS camera fixture [\#1352](https://github.com/python-kasa/python-kasa/pull/1352) (@Happy-Cadaver) +- Add C325WB\(EU\) 1.0 1.1.17 Fixture [\#1379](https://github.com/python-kasa/python-kasa/pull/1379) (@sdb9696) +- Add C100 4.0 1.3.14 Fixture [\#1378](https://github.com/python-kasa/python-kasa/pull/1378) (@sdb9696) + +**Documentation updates:** + +- Update docs for Tapo Lab Third-Party compatibility [\#1380](https://github.com/python-kasa/python-kasa/pull/1380) (@sdb9696) +- Add homebridge-kasa-python link to README [\#1367](https://github.com/python-kasa/python-kasa/pull/1367) (@rytilahti) +- Add link to related homeassistant-tapo-control [\#1333](https://github.com/python-kasa/python-kasa/pull/1333) (@rytilahti) +- Update docs for new FeatureAttribute behaviour [\#1365](https://github.com/python-kasa/python-kasa/pull/1365) (@sdb9696) + +**Project maintenance:** + +- Add P135 1.0 1.2.0 fixture [\#1397](https://github.com/python-kasa/python-kasa/pull/1397) (@sdb9696) +- Update C520WS fixture with new methods [\#1384](https://github.com/python-kasa/python-kasa/pull/1384) (@sdb9696) +- Miscellaneous minor fixes to dump\_devinfo [\#1382](https://github.com/python-kasa/python-kasa/pull/1382) (@sdb9696) +- Add timeout parameter to dump\_devinfo [\#1381](https://github.com/python-kasa/python-kasa/pull/1381) (@sdb9696) +- Force single for some smartcam requests [\#1374](https://github.com/python-kasa/python-kasa/pull/1374) (@sdb9696) +- Pass raw components to SmartChildDevice init [\#1363](https://github.com/python-kasa/python-kasa/pull/1363) (@sdb9696) +- Fix line endings in device\_fixtures.py [\#1361](https://github.com/python-kasa/python-kasa/pull/1361) (@sdb9696) +- Tweak RELEASING.md instructions for patch releases [\#1347](https://github.com/python-kasa/python-kasa/pull/1347) (@sdb9696) +- Scrub more vacuum keys [\#1328](https://github.com/python-kasa/python-kasa/pull/1328) (@rytilahti) +- Remove unnecessary check for python \<3.10 [\#1326](https://github.com/python-kasa/python-kasa/pull/1326) (@rytilahti) +- Add vacuum component queries to dump\_devinfo [\#1320](https://github.com/python-kasa/python-kasa/pull/1320) (@rytilahti) +- Handle missing mgt\_encryption\_schm in discovery [\#1318](https://github.com/python-kasa/python-kasa/pull/1318) (@sdb9696) +- Follow main package structure for tests [\#1317](https://github.com/python-kasa/python-kasa/pull/1317) (@rytilahti) +- Handle smartcam device blocked response [\#1393](https://github.com/python-kasa/python-kasa/pull/1393) (@sdb9696) +- Handle KeyboardInterrupts in the cli better [\#1391](https://github.com/python-kasa/python-kasa/pull/1391) (@sdb9696) +- Simplify get\_protocol to prevent clashes with smartcam and robovac [\#1377](https://github.com/python-kasa/python-kasa/pull/1377) (@sdb9696) +- Add smartcam modules to package inits [\#1376](https://github.com/python-kasa/python-kasa/pull/1376) (@sdb9696) +- Enable saving of fixture files without git clone [\#1375](https://github.com/python-kasa/python-kasa/pull/1375) (@sdb9696) +- Add new methods to dump\_devinfo [\#1373](https://github.com/python-kasa/python-kasa/pull/1373) (@sdb9696) +- Update cli, light modules, and docs to use FeatureAttributes [\#1364](https://github.com/python-kasa/python-kasa/pull/1364) (@sdb9696) +- Update dump\_devinfo for raw discovery json and common redactors [\#1358](https://github.com/python-kasa/python-kasa/pull/1358) (@sdb9696) + +## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1) @@ -46,28 +303,28 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im - Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696) - Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696) - Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696) -- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) - Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696) -- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) - Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril) - Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696) +- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) +- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) - Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696) **Fixed bugs:** - TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) -- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) - How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306) - kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267) - device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262) - Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243) - Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201) -- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) -- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) +- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) - Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti) - Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696) - Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696) - Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti) +- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) +- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) **Added support for devices:** @@ -81,13 +338,11 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im **Documentation updates:** - Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696) -- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) - Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696) +- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) **Project maintenance:** -- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) -- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) - Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696) - Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696) - Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696) @@ -117,15 +372,17 @@ Special thanks to @ryenitcher and @Puxtril for their new contributions to the im - Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti) - Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696) - Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696) -- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) - Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) -- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) - Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) -- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) - Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) -- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) - Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti) - Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti) +- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) +- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) +- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) +- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) +- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) +- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) **Closed issues:** diff --git a/README.md b/README.md index f59f36770..f2e7db84e 100644 --- a/README.md +++ b/README.md @@ -178,26 +178,32 @@ The following devices have been tested and confirmed as working. If your device > [!NOTE] > The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. +> [!NOTE] +> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app. +> Alternatively, you can factory reset and then prevent the device from accessing the internet. + ### Supported Kasa devices -- **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 +- **Plugs**: EP10, EP25[^2], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] -- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 -- **Light Strips**: KL400L5, KL420L5, KL430 +- **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] +- **Bulbs**: KL110, KL110B, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB130 +- **Light Strips**: KL400L10, KL400L5, KL420L5, KL430 - **Hubs**: KH100[^1] - **Hub-Connected Devices[^3]**: KE100[^1] ### Supported Tapo[^1] devices -- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 -- **Power Strips**: P300, P304M, TP25 -- **Wall Switches**: S500D, S505, S505D -- **Bulbs**: L510B, L510E, L530E, L630 +- **Plugs**: P100, P105, P110, P110M, P115, P125M, P135, TP10, TP15 +- **Power Strips**: P210M, P300, P304M, P306, P316M, TP25 +- **Wall Switches**: S210, S220, S500, S500D, S505, S505D, S515D, TS15 +- **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L530EA, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C210, TC65 +- **Cameras**: C100, C101, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC40, TC65, TC70 +- **Doorbells and chimes**: D100C, D130, D230 +- **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 @@ -223,10 +229,12 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf * [Home Assistant](https://www.home-assistant.io/integrations/tplink/) * [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa) +* [Homebridge Kasa Python Plug-In](https://github.com/ZeliardM/homebridge-kasa-python) ### Other related projects * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) + * [Home Assistant integration](https://github.com/JurajNyiri/HomeAssistant-Tapo-Control) * [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100) * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) * [plugp100, another tapo library](https://github.com/petretiandrea/plugp100) diff --git a/RELEASING.md b/RELEASING.md index 032aeb0c5..b5587d601 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,9 +44,10 @@ uv lock --upgrade uv sync --all-extras ``` -### Run pre-commit and tests +### Update and run pre-commit and tests ```bash +pre-commit autoupdate uv run pre-commit run --all-files uv run pytest -n auto ``` @@ -124,6 +125,12 @@ git push upstream release/$NEW_RELEASE -u gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master ``` +To update the PR after refreshing the changelog: + +``` +gh pr edit --body "$RELEASE_NOTES" +``` + #### Merge the PR once the CI passes Create a squash commit and add the markdown from the PR description to the commit description. @@ -283,9 +290,12 @@ git rebase upstream/master git checkout -b janitor/merge_patch git fetch upstream patch git merge upstream/patch --no-commit +# If there are any merge conflicts run the following command which will simply make master win +# Do not run it if there are no conflicts as it will end up checking out upstream/master git diff --name-only --diff-filter=U | xargs git checkout upstream/master +# Check the diff is as expected git diff --staged -# The only diff should be the version in pyproject.toml and CHANGELOG.md +# The only diff should be the version in pyproject.toml and uv.lock, and CHANGELOG.md # unless a change made on patch that was not part of a cherry-pick commit # If there are any other unexpected diffs `git checkout upstream/master [thefilename]` git commit -m "Merge patch into local master" -S diff --git a/SUPPORTED.md b/SUPPORTED.md index 034372b0e..3e4eb5d59 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -5,6 +5,9 @@ The following devices have been tested and confirmed as working. If your device > [!NOTE] > The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. +> [!NOTE] +> Some firmware versions of Tapo Cameras will not authenticate unless you enable "Tapo Lab" > "Third-Party Compatibility" in the native Tapo app. +> Alternatively, you can factory reset and then prevent the device from accessing the internet. @@ -17,6 +20,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **EP10** - Hardware: 1.0 (US) / Firmware: 1.0.2 - **EP25** + - Hardware: 1.0 (US) / Firmware: 1.0.14 - Hardware: 2.6 (US) / Firmware: 1.0.1[^1] - Hardware: 2.6 (US) / Firmware: 1.0.2[^1] - **HS100** @@ -90,6 +94,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **HS210** - Hardware: 1.0 (US) / Firmware: 1.5.8 - Hardware: 2.0 (US) / Firmware: 1.1.5 + - Hardware: 3.0 (US) / Firmware: 1.0.10 - **HS220** - Hardware: 1.0 (US) / Firmware: 1.5.7 - Hardware: 2.0 (US) / Firmware: 1.0.3 @@ -97,6 +102,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.6 +- **KS200** + - Hardware: 1.0 (US) / Firmware: 1.0.8 - **KS200M** - Hardware: 1.0 (US) / Firmware: 1.0.10 - Hardware: 1.0 (US) / Firmware: 1.0.11 @@ -112,8 +119,10 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KS225** - Hardware: 1.0 (US) / Firmware: 1.0.2[^1] - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] + - Hardware: 1.0 (US) / Firmware: 1.1.1[^1] - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 + - Hardware: 2.0 (US) / Firmware: 1.0.11 - **KS240** - Hardware: 1.0 (US) / Firmware: 1.0.4[^1] - Hardware: 1.0 (US) / Firmware: 1.0.5[^1] @@ -123,6 +132,8 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KL110** - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **KL110B** + - Hardware: 1.0 (UN) / Firmware: 1.8.11 - **KL120** - Hardware: 1.0 (US) / Firmware: 1.8.11 - Hardware: 1.0 (US) / Firmware: 1.8.6 @@ -141,12 +152,19 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - **KL60** - Hardware: 1.0 (UN) / Firmware: 1.1.4 - Hardware: 1.0 (US) / Firmware: 1.1.13 +- **LB100** + - Hardware: 1.0 (US) / Firmware: 1.8.11 - **LB110** - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **LB130** + - Hardware: 1.0 (US) / Firmware: 1.8.11 ### Light Strips +- **KL400L10** + - Hardware: 1.0 (US) / Firmware: 1.0.10 - **KL400L5** + - Hardware: 1.0 (US) / Firmware: 1.0.11 - Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.8 - **KL420L5** @@ -183,7 +201,10 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0.0 (US) / Firmware: 1.1.3 - Hardware: 1.0.0 (US) / Firmware: 1.3.7 - Hardware: 1.0.0 (US) / Firmware: 1.4.0 +- **P105** + - Hardware: 1.0 (US) / Firmware: 1.2.5 - **P110** + - Hardware: 1.0 (AU) / Firmware: 1.3.1 - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (UK) / Firmware: 1.3.0 @@ -192,45 +213,76 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.2.3 - **P115** - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (US) / Firmware: 1.1.3 - **P125M** - Hardware: 1.0 (US) / Firmware: 1.1.0 - **P135** - Hardware: 1.0 (US) / Firmware: 1.0.5 + - Hardware: 1.0 (US) / Firmware: 1.2.0 +- **TP10** + - Hardware: 1.0 (IT) / Firmware: 1.2.5 - **TP15** - Hardware: 1.0 (US) / Firmware: 1.0.3 ### Power Strips +- **P210M** + - Hardware: 1.0 (US) / Firmware: 1.0.3 - **P300** - Hardware: 1.0 (EU) / Firmware: 1.0.13 - Hardware: 1.0 (EU) / Firmware: 1.0.15 - Hardware: 1.0 (EU) / Firmware: 1.0.7 - **P304M** - Hardware: 1.0 (UK) / Firmware: 1.0.3 +- **P306** + - Hardware: 1.0 (US) / Firmware: 1.1.2 +- **P316M** + - Hardware: 1.6 (US) / Firmware: 1.0.5 - **TP25** - Hardware: 1.0 (US) / Firmware: 1.0.2 ### Wall Switches +- **S210** + - Hardware: 1.0 (EU) / Firmware: 1.9.0 +- **S220** + - Hardware: 1.0 (EU) / Firmware: 1.9.0 +- **S500** + - Hardware: 1.0 (US) / Firmware: 1.2.0 - **S500D** - Hardware: 1.0 (US) / Firmware: 1.0.5 - **S505** - Hardware: 1.0 (US) / Firmware: 1.0.2 - **S505D** - Hardware: 1.0 (US) / Firmware: 1.1.0 +- **S515D** + - Hardware: 1.6 (US) / Firmware: 1.0.4 +- **TS15** + - Hardware: 1.0 (US) / Firmware: 1.2.2 ### Bulbs +- **L430C** + - Hardware: 1.0 (EU) / Firmware: 1.0.4 +- **L430P** + - Hardware: 1.0 (EU) / Firmware: 1.0.9 - **L510B** - Hardware: 3.0 (EU) / Firmware: 1.0.5 - **L510E** - Hardware: 3.0 (US) / Firmware: 1.0.5 - Hardware: 3.0 (US) / Firmware: 1.1.2 +- **L530B** + - Hardware: 3.0 (EU) / Firmware: 1.1.9 - **L530E** - Hardware: 3.0 (EU) / Firmware: 1.0.6 - Hardware: 3.0 (EU) / Firmware: 1.1.0 - Hardware: 3.0 (EU) / Firmware: 1.1.6 + - Hardware: 2.0 (TW) / Firmware: 1.1.1 - Hardware: 2.0 (US) / Firmware: 1.1.0 +- **L530EA** + - Hardware: 1.0 (EU) / Firmware: 1.4.2 +- **L535E** + - Hardware: 3.0 (EU) / Firmware: 1.1.8 - **L630** - Hardware: 1.0 (EU) / Firmware: 1.1.2 @@ -248,24 +300,68 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.3 - **L930-5** + - Hardware: 1.0 (EU) / Firmware: 1.2.5 - Hardware: 1.0 (US) / Firmware: 1.1.2 ### Cameras +- **C100** + - Hardware: 4.0 / Firmware: 1.3.14 +- **C101** + - Hardware: 5.0 (US) / Firmware: 1.4.3 +- **C110** + - Hardware: 2.0 (EU) / Firmware: 1.4.3 - **C210** + - Hardware: 2.0 / Firmware: 1.3.11 + - Hardware: 1.0 (EU) / Firmware: 1.4.7 - Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **C220** + - Hardware: 1.0 (EU) / Firmware: 1.2.2 + - Hardware: 1.0 (EU) / Firmware: 1.2.5 +- **C225** + - Hardware: 2.0 (US) / Firmware: 1.0.11 +- **C325WB** + - Hardware: 1.0 (EU) / Firmware: 1.1.17 +- **C460** + - Hardware: 1.0 (CA) / Firmware: 1.2.0 +- **C520WS** + - Hardware: 1.0 (US) / Firmware: 1.2.8 +- **C720** + - Hardware: 1.0 (US) / Firmware: 1.2.3 +- **TC40** + - Hardware: 2.0 (EU) / Firmware: 1.0.4 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 +- **TC70** + - Hardware: 3.0 / Firmware: 1.3.11 + +### Doorbells and chimes + +- **D100C** + - Hardware: 1.0 (US) / Firmware: 1.1.3 +- **D130** + - Hardware: 1.0 (US) / Firmware: 1.1.9 +- **D230** + - Hardware: 1.20 (EU) / Firmware: 1.1.19 + +### Vacuums + +- **RV20 Max Plus** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 +- **RV30 Max** + - Hardware: 1.0 (US) / Firmware: 1.2.0 ### Hubs - **H100** + - Hardware: 1.0 (AU) / Firmware: 1.5.23 - Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.5 - **H200** - Hardware: 1.0 (EU) / Firmware: 1.3.2 + - Hardware: 1.0 (EU) / Firmware: 1.3.6 - Hardware: 1.0 (US) / Firmware: 1.3.6 ### Hub-Connected Devices @@ -278,6 +374,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T100** - Hardware: 1.0 (EU) / Firmware: 1.12.0 + - Hardware: 1.0 (US) / Firmware: 1.12.0 - **T110** - Hardware: 1.0 (EU) / Firmware: 1.8.0 - Hardware: 1.0 (EU) / Firmware: 1.9.0 diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 18005990f..fff57370f 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -10,8 +10,6 @@ from __future__ import annotations -import base64 -import collections.abc import dataclasses import json import logging @@ -19,6 +17,7 @@ import sys import traceback from collections import defaultdict, namedtuple +from collections.abc import Callable from pathlib import Path from pprint import pprint from typing import Any @@ -39,30 +38,83 @@ ) from kasa.device_factory import get_protocol from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily -from kasa.discover import DiscoveryResult +from kasa.discover import ( + NEW_DISCOVERY_REDACTORS, + DiscoveredRaw, + DiscoveryResult, +) from kasa.exceptions import SmartErrorCode from kasa.protocols import IotProtocol +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import redact_data from kasa.protocols.smartcamprotocol import ( SmartCamProtocol, _ChildCameraProtocolWrapper, ) +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartChildDevice, SmartDevice -from kasa.smartcam import SmartCamDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT Call = namedtuple("Call", "module method") -FixtureResult = namedtuple("FixtureResult", "filename, folder, data") +FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix") SMART_FOLDER = "tests/fixtures/smart/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" +SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child/" IOT_FOLDER = "tests/fixtures/iot/" +SMART_PROTOCOL_SUFFIX = "SMART" +SMARTCAM_SUFFIX = "SMARTCAM" +SMART_CHILD_SUFFIX = "SMART.CHILD" +SMARTCAM_CHILD_SUFFIX = "SMARTCAM.CHILD" +IOT_SUFFIX = "IOT" + +NO_GIT_FIXTURE_FOLDER = "kasa-fixtures" + ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] _LOGGER = logging.getLogger(__name__) +def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): + """Wrap the redactors for dump_devinfo. + + Will replace all partial REDACT_ values with zeros. + If the data item is already scrubbed by dump_devinfo will leave as-is. + """ + + def _wrap(key: str) -> Any: + def _wrapped(redactor: Callable[[Any], Any] | None) -> Any | None: + if redactor is None: + return lambda x: "**SCRUBBED**" + + def _redact_to_zeros(x: Any) -> Any: + if isinstance(x, str) and "REDACT" in x: + return re.sub(r"\w", "0", x) + if isinstance(x, dict): + for k, v in x.items(): + x[k] = _redact_to_zeros(v) + return x + + def _scrub(x: Any) -> Any: + if key in {"ip", "local_ip"}: + return "127.0.0.123" + # Already scrubbed by dump_devinfo + if isinstance(x, str) and "SCRUBBED" in x: + return x + default = redactor(x) + return _redact_to_zeros(default) + + return _scrub + + return _wrapped(redactors[key]) + + return {key: _wrap(key) for key in redactors} + + @dataclasses.dataclass class SmartCall: """Class for smart and smartcam calls.""" @@ -74,103 +126,6 @@ class SmartCall: supports_multiple: bool = True -def scrub(res): - """Remove identifiers from the given dict.""" - keys_to_scrub = [ - "deviceId", - "fwId", - "hwId", - "oemId", - "mac", - "mic_mac", - "latitude_i", - "longitude_i", - "latitude", - "longitude", - "la", # lat on ks240 - "lo", # lon on ks240 - "owner", - "device_id", - "ip", - "ssid", - "hw_id", - "fw_id", - "oem_id", - "nickname", - "alias", - "bssid", - "channel", - "original_device_id", # for child devices on strips - "parent_device_id", # for hub children - "setup_code", # matter - "setup_payload", # matter - "mfi_setup_code", # mfi_ for homekit - "mfi_setup_id", - "mfi_token_token", - "mfi_token_uuid", - "dev_id", - "device_name", - "device_alias", - "connect_ssid", - "encrypt_info", - "local_ip", - "username", - ] - - for k, v in res.items(): - if isinstance(v, collections.abc.Mapping): - if k == "encrypt_info": - if "data" in v: - v["data"] = "" - if "key" in v: - v["key"] = "" - else: - res[k] = scrub(res.get(k)) - elif ( - isinstance(v, list) - and len(v) > 0 - and isinstance(v[0], collections.abc.Mapping) - ): - res[k] = [scrub(vi) for vi in v] - else: - if k in keys_to_scrub: - if k in ["mac", "mic_mac"]: - # Some macs have : or - as a separator and others do not - if len(v) == 12: - v = f"{v[:6]}000000" - else: - delim = ":" if ":" in v else "-" - rest = delim.join( - format(s, "02x") for s in bytes.fromhex("000000") - ) - v = f"{v[:8]}{delim}{rest}" - elif k in ["latitude", "latitude_i", "longitude", "longitude_i"]: - v = 0 - elif k in ["ip", "local_ip"]: - v = "127.0.0.123" - elif k in ["ssid"]: - # Need a valid base64 value here - v = base64.b64encode(b"#MASKED_SSID#").decode() - elif k in ["nickname"]: - v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in ["alias", "device_alias", "device_name", "username"]: - v = "#MASKED_NAME#" - elif isinstance(res[k], int): - v = 0 - elif k in ["device_id", "dev_id"] and "SCRUBBED" in v: - pass # already scrubbed - elif k == ["device_id", "dev_id"] and len(v) > 40: - # retain the last two chars when scrubbing child ids - end = v[-2:] - v = re.sub(r"\w", "0", v) - v = v[:40] + end - else: - v = re.sub(r"\w", "0", v) - - res[k] = v - return res - - def default_to_regular(d): """Convert nested defaultdicts to regular ones. @@ -195,13 +150,23 @@ async def handle_device( ] for fixture_result in fixture_results: - save_filename = Path(basedir) / fixture_result.folder / fixture_result.filename + save_folder = Path(basedir) / fixture_result.folder + if save_folder.exists(): + save_filename = save_folder / f"{fixture_result.filename}.json" + else: + # If being run without git clone + save_folder = Path(basedir) / NO_GIT_FIXTURE_FOLDER + save_folder.mkdir(exist_ok=True) + save_filename = ( + save_folder + / f"{fixture_result.filename}-{fixture_result.protocol_suffix}.json" + ) - pprint(scrub(fixture_result.data)) + pprint(fixture_result.data) if autosave: save = "y" else: - save = click.prompt( + save = await click.prompt( f"Do you want to save the above content to {save_filename} (y/n)" ) if save == "y": @@ -288,6 +253,12 @@ async def handle_device( type=bool, help="Set flag if the device encryption uses https.", ) +@click.option( + "--timeout", + required=False, + default=15, + help="Timeout for queries.", +) @click.option("--port", help="Port override", type=int) async def cli( host, @@ -305,6 +276,7 @@ async def cli( device_family, login_version, port, + timeout, ): """Generate devinfo files for devices. @@ -313,6 +285,11 @@ async def cli( if debug: logging.basicConfig(level=logging.DEBUG) + raw_discovery = {} + + def capture_raw(discovered: DiscoveredRaw): + raw_discovery[discovered["meta"]["ip"]] = discovered["discovery_response"] + credentials = Credentials(username=username, password=password) if host is not None: if discovery_info: @@ -323,13 +300,16 @@ async def cli( connection_type = DeviceConnectionParameters.from_values( dr.device_type, dr.mgt_encrypt_schm.encrypt_type, - dr.mgt_encrypt_schm.lv, + login_version=dr.mgt_encrypt_schm.lv, + https=dr.mgt_encrypt_schm.is_support_https, + http_port=dr.mgt_encrypt_schm.http_port, ) dc = DeviceConfig( host=host, connection_type=connection_type, port_override=port, credentials=credentials, + timeout=timeout, ) device = await Device.connect(config=dc) await handle_device( @@ -351,6 +331,7 @@ async def cli( port_override=port, credentials=credentials, connection_type=ctype, + timeout=timeout, ) if protocol := get_protocol(config): await handle_device(basedir, autosave, protocol, batch_size=batch_size) @@ -365,12 +346,17 @@ async def cli( credentials=credentials, port=port, discovery_timeout=discovery_timeout, + timeout=timeout, + on_discovered_raw=capture_raw, ) + discovery_info = raw_discovery[device.host] + if decrypted_data := device._discovery_info.get("decrypted_data"): + discovery_info["result"]["decrypted_data"] = decrypted_data await handle_device( basedir, autosave, device.protocol, - discovery_info=device._discovery_info, + discovery_info=discovery_info, batch_size=batch_size, ) else: @@ -379,21 +365,29 @@ async def cli( f" {target}. Use --target to override." ) devices = await Discover.discover( - target=target, credentials=credentials, discovery_timeout=discovery_timeout + target=target, + credentials=credentials, + discovery_timeout=discovery_timeout, + timeout=timeout, + on_discovered_raw=capture_raw, ) click.echo(f"Detected {len(devices)} devices") for dev in devices.values(): + discovery_info = raw_discovery[dev.host] + if decrypted_data := dev._discovery_info.get("decrypted_data"): + discovery_info["result"]["decrypted_data"] = decrypted_data + await handle_device( basedir, autosave, dev.protocol, - discovery_info=dev._discovery_info, + discovery_info=discovery_info, batch_size=batch_size, ) async def get_legacy_fixture( - protocol: IotProtocol, *, discovery_info: dict[str, Any] | None + protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None ) -> FixtureResult: """Get fixture for legacy IOT style protocol.""" items = [ @@ -429,6 +423,7 @@ async def get_legacy_fixture( Call(module="smartlife.iot.LAS", method="get_adc_value"), Call(module="smartlife.iot.PIR", method="get_config"), Call(module="smartlife.iot.PIR", method="get_adc_value"), + Call(module="smartlife.iot.homekit", method="setup_info_get"), ] successes = [] @@ -463,11 +458,21 @@ async def get_legacy_fixture( _echo_error(f"Unable to query all successes at once: {ex}") finally: await protocol.close() + + final = redact_data(final, _wrap_redactors(IOT_REDACTORS)) + + # Scrub the child device ids + if children := final.get("system", {}).get("get_sysinfo", {}).get("children"): + for index, child in enumerate(children): + if "id" not in child: + _LOGGER.error("Could not find a device for the child device: %s", child) + else: + child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + if discovery_info and not discovery_info.get("system"): - # Need to recreate a DiscoverResult here because we don't want the aliases - # in the fixture, we want the actual field names as returned by the device. - dr = DiscoveryResult.from_dict(discovery_info) - final["discovery_result"] = dr.to_dict() + final["discovery_result"] = redact_data( + discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) + ) click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) @@ -477,9 +482,14 @@ async def get_legacy_fixture( hw_version = sysinfo["hw_ver"] sw_version = sysinfo["sw_ver"] sw_version = sw_version.split(" ", maxsplit=1)[0] - save_filename = f"{model}_{hw_version}_{sw_version}.json" + save_filename = f"{model}_{hw_version}_{sw_version}" copy_folder = IOT_FOLDER - return FixtureResult(filename=save_filename, folder=copy_folder, data=final) + return FixtureResult( + filename=save_filename, + folder=copy_folder, + data=final, + protocol_suffix=IOT_SUFFIX, + ) def _echo_error(msg: str): @@ -716,15 +726,6 @@ async def get_smart_test_calls(protocol: SmartProtocol): successes = [] child_device_components = {} - extra_test_calls = [ - SmartCall( - module="temp_humidity_records", - request=SmartRequest.get_raw_request("get_temp_humidity_records").to_dict(), - should_succeed=False, - child_device_id="", - ), - ] - click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( protocol, @@ -803,8 +804,6 @@ async def get_smart_test_calls(protocol: SmartProtocol): click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) - test_calls.extend(extra_test_calls) - # Child component calls for child_device_id, child_components in child_device_components.items(): test_calls.append( @@ -830,32 +829,87 @@ async def get_smart_test_calls(protocol: SmartProtocol): else: click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) - # Add the extra calls for each child - for extra_call in extra_test_calls: - extra_child_call = dataclasses.replace( - extra_call, child_device_id=child_device_id - ) - test_calls.append(extra_child_call) return test_calls, successes -def get_smart_child_fixture(response): +def get_smart_child_fixture(response, model_info, folder, suffix): """Get a seperate fixture for the child device.""" - model_info = SmartDevice._get_device_info(response, None) hw_version = model_info.hardware_version fw_version = model_info.firmware_version model = model_info.long_name if model_info.region is not None: model = f"{model}({model_info.region})" - save_filename = f"{model}_{hw_version}_{fw_version}.json" + save_filename = f"{model}_{hw_version}_{fw_version}" return FixtureResult( - filename=save_filename, folder=SMART_CHILD_FOLDER, data=response + filename=save_filename, + folder=folder, + data=response, + protocol_suffix=suffix, ) +def scrub_child_device_ids( + main_response: dict, child_responses: dict +) -> dict[str, str]: + """Scrub all the child device ids in the responses.""" + # Make the scrubbed id map + scrubbed_child_id_map = { + device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + for index, device_id in enumerate(child_responses.keys()) + if device_id != "" + } + + for child_id, response in child_responses.items(): + scrubbed_child_id = scrubbed_child_id_map[child_id] + # scrub the device id in the child's get info response + # The checks for the device_id will ensure we can get a fixture + # even if the data is unexpectedly not available although it should + # always be there + if "get_device_info" in response and "device_id" in response["get_device_info"]: + response["get_device_info"]["device_id"] = scrubbed_child_id + elif ( + basic_info := response.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info") + ) and "dev_id" in basic_info: + basic_info["dev_id"] = scrubbed_child_id + else: + _LOGGER.error( + "Cannot find device id in child get device info: %s", child_id + ) + + # Scrub the device ids in the parent for smart protocol + if gc := main_response.get("get_child_device_component_list"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_child_id_map[device_id] + for child in main_response["get_child_device_list"]["child_device_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_child_id_map[device_id] + + # Scrub the device ids in the parent for the smart camera protocol + if gc := main_response.get("getChildDeviceComponentList"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_child_id_map[device_id] + for child in main_response["getChildDeviceList"]["child_device_list"]: + if device_id := child.get("device_id"): + child["device_id"] = scrubbed_child_id_map[device_id] + continue + elif dev_id := child.get("dev_id"): + child["dev_id"] = scrubbed_child_id_map[dev_id] + continue + _LOGGER.error("Could not find a device id for the child device: %s", child) + + return scrubbed_child_id_map + + async def get_smart_fixtures( - protocol: SmartProtocol, *, discovery_info: dict[str, Any] | None, batch_size: int + protocol: SmartProtocol, + *, + discovery_info: dict[str, dict[str, Any]] | None, + batch_size: int, ) -> list[FixtureResult]: """Get fixture for new TAPO style protocol.""" if isinstance(protocol, SmartCamProtocol): @@ -907,21 +961,19 @@ async def get_smart_fixtures( finally: await protocol.close() + # Put all the successes into a dict[child_device_id or "", successes[]] device_requests: dict[str, list[SmartCall]] = {} for success in successes: device_request = device_requests.setdefault(success.child_device_id, []) device_request.append(success) - scrubbed_device_ids = { - device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}" - for index, device_id in enumerate(device_requests.keys()) - if device_id != "" - } - final = await _make_final_calls( protocol, device_requests[""], "All successes", batch_size, child_device_id="" ) fixture_results = [] + + # Make the final child calls + child_responses = {} for child_device_id, requests in device_requests.items(): if child_device_id == "": continue @@ -932,77 +984,118 @@ async def get_smart_fixtures( batch_size, child_device_id=child_device_id, ) - - scrubbed = scrubbed_device_ids[child_device_id] - if "get_device_info" in response and "device_id" in response["get_device_info"]: - response["get_device_info"]["device_id"] = scrubbed - # If the child is a different model to the parent create a seperate fixture - if "get_device_info" in final: - parent_model = final["get_device_info"]["model"] - elif "getDeviceInfo" in final: - parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][ - "device_model" - ] + child_responses[child_device_id] = response + + # scrub the child ids + scrubbed_child_id_map = scrub_child_device_ids(final, child_responses) + + # Redact data from the main device response. _wrap_redactors ensure we do + # not redact the scrubbed child device ids and replaces REDACTED_partial_id + # with zeros + final = redact_data(final, _wrap_redactors(SMART_REDACTORS)) + + # smart cam child devices provide more information in getChildDeviceList on the + # parent than they return when queried directly for getDeviceInfo so we will store + # it in the child fixture. + if smart_cam_child_list := final.get("getChildDeviceList"): + child_infos_on_parent = { + info["device_id"]: info + for info in smart_cam_child_list["child_device_list"] + } + + for child_id, response in child_responses.items(): + scrubbed_child_id = scrubbed_child_id_map[child_id] + + # Get the parent model for checking whether to create a seperate child fixture + if model := final.get("get_device_info", {}).get("model"): + parent_model = model + elif ( + device_model := final.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info", {}) + .get("device_model") + ): + parent_model = device_model else: - raise KasaException("Cannot determine parent device model.") + parent_model = None + _LOGGER.error("Cannot determine parent device model.") + + # different model smart child device if ( - "component_nego" in response - and "get_device_info" in response - and (child_model := response["get_device_info"].get("model")) + (child_model := response.get("get_device_info", {}).get("model")) + and parent_model and child_model != parent_model ): - fixture_results.append(get_smart_child_fixture(response)) + response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) + model_info = SmartDevice._get_device_info(response, None) + fixture_results.append( + get_smart_child_fixture( + response, model_info, SMART_CHILD_FOLDER, SMART_CHILD_SUFFIX + ) + ) + # different model smartcam child device + elif ( + ( + child_model := response.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info", {}) + .get("device_model") + ) + and parent_model + and child_model != parent_model + ): + response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) + # There is more info in the childDeviceList on the parent + # particularly the region is needed here. + child_info_from_parent = child_infos_on_parent[scrubbed_child_id] + response[CHILD_INFO_FROM_PARENT] = child_info_from_parent + model_info = SmartCamChild._get_device_info(response, None) + fixture_results.append( + get_smart_child_fixture( + response, model_info, SMARTCAM_CHILD_FOLDER, SMARTCAM_CHILD_SUFFIX + ) + ) + # same model child device else: cd = final.setdefault("child_devices", {}) - cd[scrubbed] = response + cd[scrubbed_child_id] = response - # Scrub the device ids in the parent for smart protocol - if gc := final.get("get_child_device_component_list"): - for child in gc["child_component_list"]: - device_id = child["device_id"] - child["device_id"] = scrubbed_device_ids[device_id] - for child in final["get_child_device_list"]["child_device_list"]: - device_id = child["device_id"] - child["device_id"] = scrubbed_device_ids[device_id] - - # Scrub the device ids in the parent for the smart camera protocol - if gc := final.get("getChildDeviceList"): - for child in gc["child_device_list"]: - if device_id := child.get("device_id"): - child["device_id"] = scrubbed_device_ids[device_id] - continue - if device_id := child.get("dev_id"): - child["dev_id"] = scrubbed_device_ids[device_id] - continue - _LOGGER.error("Could not find a device for the child device: %s", child) - - # Need to recreate a DiscoverResult here because we don't want the aliases - # in the fixture, we want the actual field names as returned by the device. + discovery_result = None if discovery_info: - dr = DiscoveryResult.from_dict(discovery_info) # type: ignore - final["discovery_result"] = dr.to_dict() + final["discovery_result"] = redact_data( + discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) + ) + discovery_result = discovery_info["result"] click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) if "get_device_info" in final: # smart protocol - model_info = SmartDevice._get_device_info(final, discovery_info) + model_info = SmartDevice._get_device_info(final, discovery_result) copy_folder = SMART_FOLDER + protocol_suffix = SMART_PROTOCOL_SUFFIX else: # smart camera protocol - model_info = SmartCamDevice._get_device_info(final, discovery_info) + model_info = SmartCamDevice._get_device_info(final, discovery_result) copy_folder = SMARTCAM_FOLDER + protocol_suffix = SMARTCAM_SUFFIX hw_version = model_info.hardware_version sw_version = model_info.firmware_version model = model_info.long_name if model_info.region is not None: model = f"{model}({model_info.region})" - save_filename = f"{model}_{hw_version}_{sw_version}.json" + save_filename = f"{model}_{hw_version}_{sw_version}" fixture_results.insert( - 0, FixtureResult(filename=save_filename, folder=copy_folder, data=final) + 0, + FixtureResult( + filename=save_filename, + folder=copy_folder, + data=final, + protocol_suffix=protocol_suffix, + ), ) return fixture_results diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py index 532c7e6a3..669a2de2e 100755 --- a/devtools/generate_supported.py +++ b/devtools/generate_supported.py @@ -13,7 +13,7 @@ from kasa.device_type import DeviceType from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.smartcam import SmartCamDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice class SupportedVersion(NamedTuple): @@ -36,6 +36,9 @@ class SupportedVersion(NamedTuple): DeviceType.Bulb: "Bulbs", DeviceType.LightStrip: "Light Strips", DeviceType.Camera: "Cameras", + DeviceType.Doorbell: "Doorbells and chimes", + DeviceType.Chime: "Doorbells and chimes", + DeviceType.Vacuum: "Vacuums", DeviceType.Hub: "Hubs", DeviceType.Sensor: "Hub-Connected Devices", DeviceType.Thermostat: "Hub-Connected Devices", @@ -49,6 +52,7 @@ class SupportedVersion(NamedTuple): SMART_FOLDER = "tests/fixtures/smart/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child" SMARTCAM_FOLDER = "tests/fixtures/smartcam/" +SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child" def generate_supported(args): @@ -66,6 +70,7 @@ def generate_supported(args): _get_supported_devices(supported, SMART_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice) _get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice) + _get_supported_devices(supported, SMARTCAM_CHILD_FOLDER, SmartCamChild) readme_updated = _update_supported_file( README_FILENAME, _supported_summary(supported), print_diffs @@ -205,7 +210,7 @@ def _get_supported_devices( fixture_data = json.load(f) model_info = device_cls._get_device_info( - fixture_data, fixture_data.get("discovery_result") + fixture_data, fixture_data.get("discovery_result", {}).get("result") ) supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type] @@ -214,7 +219,7 @@ def _get_supported_devices( smodel = stype.setdefault(model_info.long_name, []) smodel.append( SupportedVersion( - region=model_info.region, + region=model_info.region if model_info.region else "", hw=model_info.hardware_version, fw=model_info.firmware_version, auth=model_info.requires_auth, diff --git a/devtools/helpers/smartcamrequests.py b/devtools/helpers/smartcamrequests.py index 074b5774d..6c60b12a7 100644 --- a/devtools/helpers/smartcamrequests.py +++ b/devtools/helpers/smartcamrequests.py @@ -44,6 +44,11 @@ {"getWhitelampConfig": {"image": {"name": "switch"}}}, {"getMsgPushConfig": {"msg_push": {"name": ["chn1_msg_push_info"]}}}, {"getSdCardStatus": {"harddisk_manage": {"table": ["hd_info"]}}}, + { + "getLinecrossingDetectionConfig": { + "linecrossing_detection": {"name": ["detection", "arming_schedule"]} + } + }, {"getCircularRecordingConfig": {"harddisk_manage": {"name": "harddisk"}}}, {"getRecordPlan": {"record_plan": {"name": ["chn1_channel"]}}}, {"getAudioConfig": {"audio_config": {"name": ["speaker", "microphone"]}}}, @@ -60,4 +65,7 @@ {"get": {"motor": {"name": ["capability"]}}}, {"get": {"audio_capability": {"name": ["device_speaker", "device_microphone"]}}}, {"get": {"audio_config": {"name": ["speaker", "microphone"]}}}, + {"getMatterSetupInfo": {"matter": {}}}, + {"getConnectStatus": {"onboarding": {"get_connect_status": {}}}}, + {"scanApList": {"onboarding": {"scan": {}}}}, ] diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 18ae00e2b..1ff379160 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -118,6 +118,16 @@ class DynamicLightEffectParams(SmartRequestParams): enable: bool id: str | None = None + @dataclass + class GetCleanAttrParams(SmartRequestParams): + """CleanAttr params. + + Decides which cleaning settings are requested + """ + + #: type can be global or pose + type: str = "global" + @staticmethod def get_raw_request( method: str, params: SmartRequestParams | None = None @@ -415,6 +425,7 @@ def get_component_requests(component_id, ver_code): "get_trigger_logs", SmartRequest.GetTriggerLogsParams() ) ], + "temp_humidity_record": [SmartRequest.get_raw_request("get_temp_humidity_records")], "double_click": [SmartRequest.get_raw_request("get_double_click_info")], "child_device": [ SmartRequest.get_raw_request("get_child_device_list"), @@ -425,4 +436,37 @@ def get_component_requests(component_id, ver_code): "dimmer_calibration": [], "fan_control": [], "overheat_protection": [], + # Vacuum components + "clean": [ + SmartRequest.get_raw_request("getCarpetClean"), + SmartRequest.get_raw_request("getCleanRecords"), + SmartRequest.get_raw_request("getVacStatus"), + SmartRequest.get_raw_request("getAreaUnit"), + SmartRequest.get_raw_request("getCleanInfo"), + SmartRequest.get_raw_request("getCleanStatus"), + SmartRequest("getCleanAttr", SmartRequest.GetCleanAttrParams()), + ], + "battery": [SmartRequest.get_raw_request("getBatteryInfo")], + "consumables": [SmartRequest.get_raw_request("getConsumablesInfo")], + "direction_control": [], + "button_and_led": [SmartRequest.get_raw_request("getChildLockInfo")], + "speaker": [ + SmartRequest.get_raw_request("getSupportVoiceLanguage"), + SmartRequest.get_raw_request("getCurrentVoiceLanguage"), + SmartRequest.get_raw_request("getVolume"), + ], + "map": [ + SmartRequest.get_raw_request("getMapInfo"), + SmartRequest.get_raw_request("getMapData"), + ], + "auto_change_map": [SmartRequest.get_raw_request("getAutoChangeMap")], + "dust_bucket": [ + SmartRequest.get_raw_request("getAutoDustCollection"), + SmartRequest.get_raw_request("getDustCollectionInfo"), + ], + "mop": [SmartRequest.get_raw_request("getMopState")], + "do_not_disturb": [SmartRequest.get_raw_request("getDoNotDisturb")], + "charge_pose_clean": [], + "continue_breakpoint_sweep": [], + "goto_point": [], } diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 0ddbed7fa..848e33dc6 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -286,8 +286,7 @@ def main( operator.local_seed = message response = None print( - f"got handshake1 in {packet_number}, " - f"looking for the response" + f"got handshake1 in {packet_number}, looking for the response" ) while ( True diff --git a/devtools/update_fixtures.py b/devtools/update_fixtures.py new file mode 100644 index 000000000..13b9996ef --- /dev/null +++ b/devtools/update_fixtures.py @@ -0,0 +1,128 @@ +"""Module to mass update fixture files.""" + +import json +import logging +from collections.abc import Callable +from pathlib import Path + +import asyncclick as click + +from devtools.dump_devinfo import _wrap_redactors +from kasa.discover import NEW_DISCOVERY_REDACTORS, redact_data +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS + +FIXTURE_FOLDER = "tests/fixtures/" + +_LOGGER = logging.getLogger(__name__) + + +def update_fixtures(update_func: Callable[[dict], bool], *, dry_run: bool) -> None: + """Run the update function against the fixtures.""" + for file in Path(FIXTURE_FOLDER).glob("**/*.json"): + with file.open("r") as f: + fixture_data = json.load(f) + + if file.parent.name == "serialization": + continue + changed = update_func(fixture_data) + if changed: + click.echo(f"Will update {file.name}\n") + if changed and not dry_run: + with file.open("w") as f: + json.dump(fixture_data, f, sort_keys=True, indent=4) + f.write("\n") + + +def _discovery_result_update(info) -> bool: + """Update discovery_result to be the raw result and error_code.""" + if (disco_result := info.get("discovery_result")) and "result" not in disco_result: + info["discovery_result"] = { + "result": disco_result, + "error_code": 0, + } + return True + return False + + +def _child_device_id_update(info) -> bool: + """Update child device ids to be the scrubbed ids from dump_devinfo.""" + changed = False + if get_child_device_list := info.get("get_child_device_list"): + child_device_list = get_child_device_list["child_device_list"] + child_component_list = info["get_child_device_component_list"][ + "child_component_list" + ] + for index, child_device in enumerate(child_device_list): + child_component = child_component_list[index] + if "SCRUBBED" not in child_device["device_id"]: + dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + click.echo( + f"child_device_id{index}: {child_device['device_id']} -> {dev_id}" + ) + child_device["device_id"] = dev_id + child_component["device_id"] = dev_id + changed = True + + if children := info.get("system", {}).get("get_sysinfo", {}).get("children"): + for index, child_device in enumerate(children): + if "SCRUBBED" not in child_device["id"]: + dev_id = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" + click.echo(f"child_device_id{index}: {child_device['id']} -> {dev_id}") + child_device["id"] = dev_id + changed = True + + return changed + + +def _diff_data(fullkey, data1, data2, diffs): + if isinstance(data1, dict): + for k, v in data1.items(): + _diff_data(fullkey + "/" + k, v, data2[k], diffs) + elif isinstance(data1, list): + for index, item in enumerate(data1): + _diff_data(fullkey + "/" + str(index), item, data2[index], diffs) + elif data1 != data2: + diffs[fullkey] = (data1, data2) + + +def _redactor_result_update(info) -> bool: + """Update fixtures with the output using the common redactors.""" + changed = False + + redactors = IOT_REDACTORS if "system" in info else SMART_REDACTORS + + for key, val in info.items(): + if not isinstance(val, dict): + continue + if key == "discovery_result": + info[key] = redact_data(val, _wrap_redactors(NEW_DISCOVERY_REDACTORS)) + else: + info[key] = redact_data(val, _wrap_redactors(redactors)) + diffs: dict[str, tuple[str, str]] = {} + _diff_data(key, val, info[key], diffs) + if diffs: + for k, v in diffs.items(): + click.echo(f"{k}: {v[0]} -> {v[1]}") + changed = True + + return changed + + +@click.option( + "--dry-run/--no-dry-run", + default=False, + is_flag=True, + type=bool, + help="Perform a dry run without saving.", +) +@click.command() +async def cli(dry_run: bool) -> None: + """Cli method fo rupdating fixtures.""" + update_fixtures(_discovery_result_update, dry_run=dry_run) + update_fixtures(_child_device_id_update, dry_run=dry_run) + update_fixtures(_redactor_result_update, dry_run=dry_run) + + +if __name__ == "__main__": + cli() diff --git a/docs/source/featureattributes.md b/docs/source/featureattributes.md new file mode 100644 index 000000000..69285ad46 --- /dev/null +++ b/docs/source/featureattributes.md @@ -0,0 +1,13 @@ +Some modules have attributes that may not be supported by the device. +These attributes will be annotated with a `FeatureAttribute` return type. +For example: + +```py + @property + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """Return the current HSV state of the bulb.""" +``` + +You can test whether a `FeatureAttribute` is supported by the device with {meth}`kasa.Module.has_feature` +or {meth}`kasa.Module.get_feature` which will return `None` if not supported. +Calling these methods on attributes not annotated with a `FeatureAttribute` return type will return an error. diff --git a/docs/source/guides/energy.md b/docs/source/guides/energy.md index d7b5727c3..a177cd1ad 100644 --- a/docs/source/guides/energy.md +++ b/docs/source/guides/energy.md @@ -1,6 +1,10 @@ # Get Energy Consumption and Usage Statistics +:::{note} +The documentation on this page applies only to KASA-branded devices. +::: + :::{note} In order to use the helper methods to calculate the statistics correctly, your devices need to have correct time set. The devices use NTP (123/UDP) and public servers from [NTP Pool Project](https://www.ntppool.org/) to synchronize their time. diff --git a/docs/source/guides/strip.md b/docs/source/guides/strip.md index d1377eab8..b6e914cc4 100644 --- a/docs/source/guides/strip.md +++ b/docs/source/guides/strip.md @@ -8,3 +8,10 @@ .. automodule:: kasa.smart.modules.childdevice :noindex: ``` + +## Pairing and unpairing + +```{eval-rst} +.. automodule:: kasa.interfaces.childsetup + :noindex: +``` diff --git a/docs/source/reference.md b/docs/source/reference.md index f4771ac5d..90493c9c2 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -13,11 +13,13 @@ ## Device +% N.B. Credentials clashes with autodoc ```{eval-rst} .. autoclass:: Device :members: :undoc-members: + :exclude-members: Credentials ``` @@ -28,7 +30,6 @@ .. autoclass:: Credentials :members: :undoc-members: - :noindex: ``` @@ -61,15 +62,11 @@ ```{eval-rst} .. autoclass:: Module - :noindex: :members: - :inherited-members: - :undoc-members: ``` ```{eval-rst} .. autoclass:: Feature - :noindex: :members: :inherited-members: :undoc-members: @@ -77,7 +74,6 @@ ```{eval-rst} .. automodule:: kasa.interfaces - :noindex: :members: :inherited-members: :undoc-members: @@ -85,63 +81,28 @@ ## Protocols and transports -```{eval-rst} -.. autoclass:: kasa.protocols.BaseProtocol - :members: - :inherited-members: - :undoc-members: -``` - -```{eval-rst} -.. autoclass:: kasa.protocols.IotProtocol - :members: - :inherited-members: - :undoc-members: -``` ```{eval-rst} -.. autoclass:: kasa.protocols.SmartProtocol +.. automodule:: kasa.protocols :members: - :inherited-members: + :imported-members: :undoc-members: + :exclude-members: SmartErrorCode + :no-index: ``` ```{eval-rst} -.. autoclass:: kasa.transports.BaseTransport +.. automodule:: kasa.transports :members: - :inherited-members: + :imported-members: :undoc-members: + :no-index: ``` -```{eval-rst} -.. autoclass:: kasa.transports.XorTransport - :members: - :inherited-members: - :undoc-members: -``` -```{eval-rst} -.. autoclass:: kasa.transports.KlapTransport - :members: - :inherited-members: - :undoc-members: -``` - -```{eval-rst} -.. autoclass:: kasa.transports.KlapTransportV2 - :members: - :inherited-members: - :undoc-members: -``` +## Errors and exceptions -```{eval-rst} -.. autoclass:: kasa.transports.AesTransport - :members: - :inherited-members: - :undoc-members: -``` -## Errors and exceptions ```{eval-rst} .. autoclass:: kasa.exceptions.KasaException @@ -171,3 +132,4 @@ .. autoclass:: kasa.exceptions.TimeoutError :members: :undoc-members: +``` diff --git a/docs/source/topics.md b/docs/source/topics.md index 0dcc60d19..f7d0cdd50 100644 --- a/docs/source/topics.md +++ b/docs/source/topics.md @@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property. ## Modules and Features The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate modules. -While the individual device-type specific classes provide an easy access for the most import features, -you can also access individual modules through {attr}`kasa.Device.modules`. -You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`. +While the device class provides easy access for most device related attributes, +for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`. +The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection. -```{note} -If you only need some module-specific information, -you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`. -``` +Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module. +They allow for instrospection and can be accessed through {attr}`kasa.Device.features`. +Attributes can be accessed via a `Feature` or a module attribute depending on the use case. +Modules tend to provide richer functionality but using the features does not require an understanding of the module api. + +:::{include} featureattributes.md +::: (topics-protocols-and-transports)= ## Protocols and Transports @@ -137,96 +140,3 @@ The base exception for all library errors is {class}`KasaException `. - If the device fails to respond within a timeout the library raises a {class}`TimeoutError `. - All other failures will raise the base {class}`KasaException ` class. - - diff --git a/docs/tutorial.py b/docs/tutorial.py index 8d0a14354..4f4397ab1 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -6,13 +6,14 @@ >>> devices = await Discover.discover(username="user@example.com", password="great_password") >>> for dev in devices.values(): ->>> await dev.update() ->>> print(dev.host) +... await dev.update() +... print(dev.host) 127.0.0.1 127.0.0.2 127.0.0.3 127.0.0.4 127.0.0.5 +127.0.0.6 :meth:`~kasa.Discover.discover_single` returns a single device by hostname: @@ -40,7 +41,7 @@ key from :class:`~kasa.Module`. Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device. -You can check the availability using ``is_``-prefixed properties like `is_color`. +You can check the availability using ``has_feature()`` method. >>> from kasa import Module >>> Module.Light in dev.modules @@ -52,23 +53,26 @@ >>> await dev.update() >>> light.brightness 50 ->>> light.is_color +>>> light.has_feature("hsv") True ->>> if light.is_color: ->>> print(light.hsv) +>>> if light.has_feature("hsv"): +... print(light.hsv) HSV(hue=0, saturation=100, value=50) You can test if a module is supported by using `get` to access it. >>> if effect := dev.modules.get(Module.LightEffect): ->>> print(effect.effect) ->>> print(effect.effect_list) ->>> if effect := dev.modules.get(Module.LightEffect): ->>> await effect.set_effect("Party") ->>> await dev.update() ->>> print(effect.effect) +... print(effect.effect) +... print(effect.effect_list) Off ['Off', 'Party', 'Relax'] + +You can then interact with the module: + +>>> if effect := dev.modules.get(Module.LightEffect): +... _ = await effect.set_effect("Party") +... await dev.update() +... print(effect.effect) Party Individual pieces of functionality are also exposed via features which you can access via :attr:`~kasa.Device.features` and will only be present if they are supported. @@ -82,14 +86,14 @@ They are useful if you want write code that dynamically adapts as new features are added to the API. >>> if auto_update := dev.features.get("auto_update_enabled"): ->>> print(auto_update.value) +... print(auto_update.value) False >>> if auto_update: ->>> await auto_update.set_value(True) ->>> await dev.update() ->>> print(auto_update.value) +... _ = await auto_update.set_value(True) +... await dev.update() +... print(auto_update.value) True >>> for feat in dev.features.values(): ->>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 +... print(f"{feat.name}: {feat.value}") +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: \nDevice time: 2024-02-23 02:40:15+01:00\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: \nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False """ diff --git a/kasa/__init__.py b/kasa/__init__.py index d4a5022e3..b8871f997 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -38,8 +38,9 @@ from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.interfaces.thermostat import Thermostat, ThermostatState from kasa.module import Module -from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol +from kasa.protocols import BaseProtocol, IotProtocol, SmartCamProtocol, SmartProtocol from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 +from kasa.smartcam.modules.camera import StreamResolution from kasa.transports import BaseTransport __version__ = version("python-kasa") @@ -51,6 +52,7 @@ "BaseTransport", "IotProtocol", "SmartProtocol", + "SmartCamProtocol", "LightState", "TurnOnBehaviors", "TurnOnBehavior", @@ -75,6 +77,7 @@ "DeviceFamily", "ThermostatState", "Thermostat", + "StreamResolution", ] from . import iot diff --git a/kasa/cli/common.py b/kasa/cli/common.py index 649df0655..4d6c4a5dd 100644 --- a/kasa/cli/common.py +++ b/kasa/cli/common.py @@ -2,13 +2,15 @@ from __future__ import annotations +import asyncio import json import re import sys from collections.abc import Callable from contextlib import contextmanager from functools import singledispatch, update_wrapper, wraps -from typing import TYPE_CHECKING, Any, Final +from gettext import gettext +from typing import TYPE_CHECKING, Any, Final, NoReturn import asyncclick as click @@ -55,7 +57,7 @@ def echo(*args, **kwargs) -> None: _echo(*args, **kwargs) -def error(msg: str) -> None: +def error(msg: str) -> NoReturn: """Print an error and exit.""" echo(f"[bold red]{msg}[/bold red]") sys.exit(1) @@ -66,6 +68,16 @@ def json_formatter_cb(result: Any, **kwargs) -> None: if not kwargs.get("json"): return + # Calling the discover command directly always returns a DeviceDict so if host + # was specified just format the device json + if ( + (host := kwargs.get("host")) + and isinstance(result, dict) + and (dev := result.get(host)) + and isinstance(dev, Device) + ): + result = dev + @singledispatch def to_serializable(val): """Regular obj-to-string for json serialization. @@ -83,6 +95,25 @@ def _device_to_serializable(val: Device): print(json_content) +async def invoke_subcommand( + command: click.Command, + ctx: click.Context, + args: list[str] | None = None, + **extra: Any, +) -> Any: + """Invoke a click subcommand. + + Calling ctx.Invoke() treats the command like a simple callback and doesn't + process any result_callbacks so we use this pattern from the click docs + https://click.palletsprojects.com/en/stable/exceptions/#what-if-i-don-t-want-that. + """ + if args is None: + args = [] + sub_ctx = await command.make_context(command.name, args, parent=ctx, **extra) + async with sub_ctx: + return await command.invoke(sub_ctx) + + def pass_dev_or_child(wrapped_function: Callable) -> Callable: """Pass the device or child to the click command based on the child options.""" child_help = ( @@ -169,7 +200,7 @@ def _list_children(): if child_option is not None: if child_option is OPTIONAL_VALUE_FLAG: msg = _list_children() - child_index_option = click.prompt( + child_index_option = await click.prompt( f"\n{msg}\nEnter the index number of the child device", type=click.IntRange(0, len(device.children) - 1), ) @@ -238,4 +269,19 @@ async def invoke(self, ctx): except Exception as exc: _handle_exception(self._debug, exc) + def __call__(self, *args, **kwargs): + """Run the coroutine in the event loop and print any exceptions. + + python click catches KeyboardInterrupt in main, raises Abort() + and does sys.exit. asyncclick doesn't properly handle a coroutine + receiving CancelledError on a KeyboardInterrupt, so we catch the + KeyboardInterrupt here once asyncio.run has re-raised it. This + avoids large stacktraces when a user presses Ctrl-C. + """ + try: + asyncio.run(self.main(*args, **kwargs)) + except KeyboardInterrupt: + click.echo(gettext("\nAborted!"), file=sys.stderr) + sys.exit(1) + return _CommandCls diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 2e621368e..7610a7cdf 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -3,6 +3,7 @@ from __future__ import annotations from pprint import pformat as pf +from typing import TYPE_CHECKING import asyncclick as click @@ -41,8 +42,14 @@ async def state(ctx, dev: Device): echo(f"Device state: {dev.is_on}") echo(f"Time: {dev.time} (tz: {dev.timezone})") - echo(f"Hardware: {dev.hw_info['hw_ver']}") - echo(f"Software: {dev.hw_info['sw_ver']}") + echo( + f"Hardware: {dev.device_info.hardware_version}" + f"{' (' + dev.region + ')' if dev.region else ''}" + ) + echo( + f"Firmware: {dev.device_info.firmware_version}" + f"{' ' + build if (build := dev.device_info.firmware_build) else ''}" + ) echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") if verbose: echo(f"Location: {dev.location}") @@ -76,6 +83,8 @@ async def state(ctx, dev: Device): echo() from .discover import _echo_discovery_info + if TYPE_CHECKING: + assert dev._discovery_info _echo_discovery_info(dev._discovery_info) return dev.internal_state diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index e472edae7..af367e32b 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -4,6 +4,7 @@ import asyncio from pprint import pformat as pf +from typing import TYPE_CHECKING, cast import asyncclick as click @@ -14,22 +15,53 @@ Discover, UnsupportedDeviceError, ) -from kasa.discover import ConnectAttempt, DiscoveryResult +from kasa.discover import ( + NEW_DISCOVERY_REDACTORS, + ConnectAttempt, + DeviceDict, + DiscoveredRaw, + DiscoveryResult, + OnDiscoveredCallable, + OnDiscoveredRawCallable, + OnUnsupportedCallable, +) +from kasa.iot.iotdevice import _extract_sys_info +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import redact_data +from ..json import dumps as json_dumps from .common import echo, error @click.group(invoke_without_command=True) @click.pass_context -async def discover(ctx): +async def discover(ctx: click.Context): """Discover devices in the network.""" if ctx.invoked_subcommand is None: return await ctx.invoke(detail) +@discover.result_callback() +@click.pass_context +async def _close_protocols(ctx: click.Context, discovered: DeviceDict): + """Close all the device protocols if discover was invoked directly by the user.""" + if _discover_is_root_cmd(ctx): + for dev in discovered.values(): + await dev.disconnect() + return discovered + + +def _discover_is_root_cmd(ctx: click.Context) -> bool: + """Will return true if discover was invoked directly by the user.""" + root_ctx = ctx.find_root() + return ( + root_ctx.invoked_subcommand is None or root_ctx.invoked_subcommand == "discover" + ) + + @discover.command() @click.pass_context -async def detail(ctx): +async def detail(ctx: click.Context) -> DeviceDict: """Discover devices in the network using udp broadcasts.""" unsupported = [] auth_failed = [] @@ -50,10 +82,14 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> No from .device import state async def print_discovered(dev: Device) -> None: + if TYPE_CHECKING: + assert ctx.parent async with sem: try: await dev.update() except AuthenticationError: + if TYPE_CHECKING: + assert dev._discovery_info auth_failed.append(dev._discovery_info) echo("== Authentication failed for device ==") _echo_discovery_info(dev._discovery_info) @@ -63,8 +99,12 @@ async def print_discovered(dev: Device) -> None: await ctx.parent.invoke(state) echo() - discovered = await _discover(ctx, print_discovered, print_unsupported) - if ctx.parent.parent.params["host"]: + discovered = await _discover( + ctx, + print_discovered=print_discovered if _discover_is_root_cmd(ctx) else None, + print_unsupported=print_unsupported, + ) + if ctx.find_root().params["host"]: return discovered echo(f"Found {len(discovered)} devices") @@ -76,23 +116,55 @@ async def print_discovered(dev: Device) -> None: return discovered +@discover.command() +@click.option( + "--redact/--no-redact", + default=False, + is_flag=True, + type=bool, + help="Set flag to redact sensitive data from raw output.", +) +@click.pass_context +async def raw(ctx: click.Context, redact: bool) -> DeviceDict: + """Return raw discovery data returned from devices.""" + + def print_raw(discovered: DiscoveredRaw): + if redact: + redactors = ( + NEW_DISCOVERY_REDACTORS + if discovered["meta"]["port"] == Discover.DISCOVERY_PORT_2 + else IOT_REDACTORS + ) + discovered["discovery_response"] = redact_data( + discovered["discovery_response"], redactors + ) + echo(json_dumps(discovered, indent=True)) + + return await _discover(ctx, print_raw=print_raw, do_echo=False) + + @discover.command() @click.pass_context -async def list(ctx): +async def list(ctx: click.Context) -> DeviceDict: """List devices in the network in a table using udp broadcasts.""" sem = asyncio.Semaphore() async def print_discovered(dev: Device): cparams = dev.config.connection_type infostr = ( - f"{dev.host:<15} {cparams.device_family.value:<20} " - f"{cparams.encryption_type.value:<7}" + f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} " + f"{cparams.encryption_type.value:<7} {cparams.https:<5} " + f"{cparams.login_version or '-':<3}" ) async with sem: try: await dev.update() except AuthenticationError: echo(f"{infostr} - Authentication failed") + except TimeoutError: + echo(f"{infostr} - Timed out") + except Exception as ex: + echo(f"{infostr} - Error: {ex}") else: echo(f"{infostr} {dev.alias}") @@ -100,12 +172,28 @@ async def print_unsupported(unsupported_exception: UnsupportedDeviceError): if host := unsupported_exception.host: echo(f"{host:<15} UNSUPPORTED DEVICE") - echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") - return await _discover(ctx, print_discovered, print_unsupported, do_echo=False) + echo( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) + discovered = await _discover( + ctx, + print_discovered=print_discovered, + print_unsupported=print_unsupported, + do_echo=False, + ) + return discovered -async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): - params = ctx.parent.parent.params +async def _discover( + ctx: click.Context, + *, + print_discovered: OnDiscoveredCallable | None = None, + print_unsupported: OnUnsupportedCallable | None = None, + print_raw: OnDiscoveredRawCallable | None = None, + do_echo=True, +) -> DeviceDict: + params = ctx.find_root().params target = params["target"] username = params["username"] password = params["password"] @@ -117,15 +205,23 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): credentials = Credentials(username, password) if username and password else None if host: + host = cast(str, host) echo(f"Discovering device {host} for {discovery_timeout} seconds") - return await Discover.discover_single( + dev = await Discover.discover_single( host, port=port, credentials=credentials, timeout=timeout, discovery_timeout=discovery_timeout, on_unsupported=print_unsupported, + on_discovered_raw=print_raw, ) + if dev: + if print_discovered: + await print_discovered(dev) + return {host: dev} + else: + return {} if do_echo: echo(f"Discovering devices on {target} for {discovery_timeout} seconds") discovered_devices = await Discover.discover( @@ -136,23 +232,21 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): port=port, timeout=timeout, credentials=credentials, + on_discovered_raw=print_raw, ) - for device in discovered_devices.values(): - await device.protocol.close() - return discovered_devices @discover.command() @click.pass_context -async def config(ctx): +async def config(ctx: click.Context) -> DeviceDict: """Bypass udp discovery and try to show connection config for a device. Bypasses udp discovery and shows the parameters required to connect directly to the device. """ - params = ctx.parent.parent.params + params = ctx.find_root().params username = params["username"] password = params["password"] timeout = params["timeout"] @@ -167,8 +261,11 @@ async def config(ctx): host_port = host + (f":{port}" if port else "") def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: - prot, tran, dev = connect_attempt - key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + prot, tran, dev, https = connect_attempt + key_str = ( + f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + f" + {'https' if https else 'http'}" + ) result = "succeeded" if success else "failed" msg = f"Attempt to connect to {host_port} with {key_str} {result}" echo(msg) @@ -184,6 +281,7 @@ def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: f"--encrypt-type {cparams.encryption_type.value} " f"{'--https' if cparams.https else '--no-https'}" ) + return {host: dev} else: error(f"Unable to connect to {host}") @@ -196,13 +294,13 @@ def _echo_dictionary(discovery_info: dict) -> None: echo(f"\t{key_name_and_spaces}{value}") -def _echo_discovery_info(discovery_info) -> None: +def _echo_discovery_info(discovery_info: dict) -> None: # We don't have discovery info when all connection params are passed manually if discovery_info is None: return - if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: - _echo_dictionary(discovery_info["system"]["get_sysinfo"]) + if sysinfo := _extract_sys_info(discovery_info): + _echo_dictionary(sysinfo) return try: @@ -228,12 +326,14 @@ def _conditional_echo(label, value): _conditional_echo("HW Ver", dr.hw_ver) _conditional_echo("HW Ver", dr.hardware_version) _conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud) - _conditional_echo("OBD Src", dr.owner) + _conditional_echo("OBD Src", dr.obd_src) _conditional_echo("Factory Default", dr.factory_default) - _conditional_echo("Encrypt Type", dr.mgt_encrypt_schm.encrypt_type) _conditional_echo("Encrypt Type", dr.encrypt_type) - _conditional_echo("Supports HTTPS", dr.mgt_encrypt_schm.is_support_https) - _conditional_echo("HTTP Port", dr.mgt_encrypt_schm.http_port) + if mgt_encrypt_schm := dr.mgt_encrypt_schm: + _conditional_echo("Encrypt Type", mgt_encrypt_schm.encrypt_type) + _conditional_echo("Supports HTTPS", mgt_encrypt_schm.is_support_https) + _conditional_echo("HTTP Port", mgt_encrypt_schm.http_port) + _conditional_echo("Login version", mgt_encrypt_schm.lv) _conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None) _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None) diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py index 522dee7f3..a4c739f6b 100644 --- a/kasa/cli/feature.py +++ b/kasa/cli/feature.py @@ -6,10 +6,7 @@ import asyncclick as click -from kasa import ( - Device, - Feature, -) +from kasa import Device, Feature from .common import ( echo, @@ -133,7 +130,22 @@ async def feature( echo(f"{feat.name} ({name}): {feat.value}{unit}") return feat.value - value = ast.literal_eval(value) + try: + # Attempt to parse as python literal. + value = ast.literal_eval(value) + except ValueError: + # The value is probably an unquoted string, so we'll raise an error, + # and tell the user to quote the string. + raise click.exceptions.BadParameter( + f'{repr(value)} for {name} (Perhaps you forgot to "quote" the value?)' + ) from SyntaxError + except SyntaxError: + # There are likely miss-matched quotes or odd characters in the input, + # so abort and complain to the user. + raise click.exceptions.BadParameter( + f"{repr(value)} for {name}" + ) from SyntaxError + echo(f"Changing {name} from {feat.value} to {value}") response = await dev.features[name].set_value(value) await dev.update() diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py new file mode 100644 index 000000000..0bc45bf5a --- /dev/null +++ b/kasa/cli/hub.py @@ -0,0 +1,95 @@ +"""Hub-specific commands.""" + +import asyncio + +import asyncclick as click + +from kasa import Device, DeviceType, Module +from kasa.smart import SmartChildDevice + +from .common import ( + echo, + error, + pass_dev, +) + + +def pretty_category(cat: str): + """Return pretty category for paired devices.""" + return SmartChildDevice.CHILD_DEVICE_TYPE_MAP.get(cat) + + +@click.group() +@pass_dev +async def hub(dev: Device): + """Commands controlling hub child device pairing.""" + if dev.device_type is not DeviceType.Hub: + error(f"{dev} is not a hub.") + + if dev.modules.get(Module.ChildSetup) is None: + error(f"{dev} does not have child setup module.") + + +@hub.command(name="list") +@pass_dev +async def hub_list(dev: Device): + """List hub paired child devices.""" + for c in dev.children: + echo(f"{c.device_id}: {c}") + + +@hub.command(name="supported") +@pass_dev +async def hub_supported(dev: Device): + """List supported hub child device categories.""" + cs = dev.modules[Module.ChildSetup] + + for cat in cs.supported_categories: + echo(f"Supports: {cat}") + + +@hub.command(name="pair") +@click.option("--timeout", default=10) +@pass_dev +async def hub_pair(dev: Device, timeout: int): + """Pair all pairable device. + + This will pair any child devices currently in pairing mode. + """ + cs = dev.modules[Module.ChildSetup] + + echo(f"Finding new devices for {timeout} seconds...") + + pair_res = await cs.pair(timeout=timeout) + if not pair_res: + echo("No devices found.") + + for child in pair_res: + echo( + f"Paired {child['name']} ({child['device_model']}, " + f"{pretty_category(child['category'])}) with id {child['device_id']}" + ) + + +@hub.command(name="unpair") +@click.argument("device_id") +@pass_dev +async def hub_unpair(dev, device_id: str): + """Unpair given device.""" + cs = dev.modules[Module.ChildSetup] + + # Accessing private here, as the property exposes only values + if device_id not in dev._children: + error(f"{dev} does not have children with identifier {device_id}") + + res = await cs.unpair(device_id=device_id) + # Give the device some time to update its internal state, just in case. + await asyncio.sleep(1) + await dev.update() + + if device_id not in dev._children: + echo(f"Unpaired {device_id}") + else: + error(f"Failed to unpair {device_id}") + + return res diff --git a/kasa/cli/lazygroup.py b/kasa/cli/lazygroup.py index a28586346..0e9435db2 100644 --- a/kasa/cli/lazygroup.py +++ b/kasa/cli/lazygroup.py @@ -66,7 +66,6 @@ def _lazy_load(self, cmd_name): # check the result to make debugging easier if not isinstance(cmd_object, click.BaseCommand): raise ValueError( - f"Lazy loading of {cmd_name} failed by returning " - "a non-command object" + f"Lazy loading of {cmd_name} failed by returning a non-command object" ) return cmd_object diff --git a/kasa/cli/light.py b/kasa/cli/light.py index b2909c59e..a77855633 100644 --- a/kasa/cli/light.py +++ b/kasa/cli/light.py @@ -25,7 +25,9 @@ def light(dev) -> None: @pass_dev_or_child async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature( + "brightness" + ): error("This device does not support brightness.") return @@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int): @pass_dev_or_child async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not ( + color_temp_feat := light.get_feature("color_temp") + ): error("Device does not support color temperature") return if temperature is None: echo(f"Color temperature: {light.color_temp}") - valid_temperature_range = light.valid_temperature_range + valid_temperature_range = color_temp_feat.range if valid_temperature_range != (0, 0): echo("(min: {}, max: {})".format(*valid_temperature_range)) else: @@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int): "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) - return light.valid_temperature_range + return color_temp_feat.range else: echo(f"Setting color temperature to {temperature}") return await light.set_color_temp(temperature, transition=transition) @@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect): @pass_dev_or_child async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" - if not (light := dev.modules.get(Module.Light)) or not light.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"): error("Device does not support colors") return diff --git a/kasa/cli/main.py b/kasa/cli/main.py index d0efc73fe..3e32c1f45 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -22,6 +22,7 @@ CatchAllExceptions, echo, error, + invoke_subcommand, json_formatter_cb, pass_dev_or_child, ) @@ -92,6 +93,8 @@ def _legacy_type_to_class(_type: str) -> Any: "hsv": "light", "temperature": "light", "effect": "light", + "vacuum": "vacuum", + "hub": "hub", }, result_callback=json_formatter_cb, ) @@ -295,9 +298,10 @@ async def cli( echo("No host name given, trying discovery..") from .discover import discover - return await ctx.invoke(discover) + return await invoke_subcommand(discover, ctx) device_updated = False + device_discovered = False if type is not None and type not in {"smart", "camera"}: from kasa.deviceconfig import DeviceConfig @@ -350,12 +354,14 @@ async def cli( return echo(f"Found hostname by alias: {dev.host}") device_updated = True - else: + else: # host will be set from .discover import discover - dev = await ctx.invoke(discover) - if not dev: + discovered = await invoke_subcommand(discover, ctx) + if not discovered: error(f"Unable to create device for {host}") + dev = discovered[host] + device_discovered = True # Skip update on specific commands, or if device factory, # that performs an update was used for the device. @@ -371,11 +377,14 @@ async def async_wrapped_device(device: Device): ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) - if ctx.invoked_subcommand is None: + # discover command has already invoked state + if ctx.invoked_subcommand is None and not device_discovered: from .device import state return await ctx.invoke(state) + return dev + @cli.command() @pass_dev_or_child @@ -405,7 +414,7 @@ async def shell(dev: Device) -> None: @click.argument("parameters", default=None, required=False) async def raw_command(ctx, module, command, parameters): """Run a raw command on the device.""" - logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) + logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) # noqa: LOG015 return await ctx.forward(cmd_command) diff --git a/kasa/cli/vacuum.py b/kasa/cli/vacuum.py new file mode 100644 index 000000000..d0ccc55a9 --- /dev/null +++ b/kasa/cli/vacuum.py @@ -0,0 +1,84 @@ +"""Module for cli vacuum commands..""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, + Module, +) + +from .common import ( + error, + pass_dev_or_child, +) + + +@click.group(invoke_without_command=False) +@click.pass_context +async def vacuum(ctx: click.Context) -> None: + """Vacuum commands.""" + + +@vacuum.group(invoke_without_command=True, name="records") +@pass_dev_or_child +async def records_group(dev: Device) -> None: + """Access cleaning records.""" + if not (rec := dev.modules.get(Module.CleanRecords)): + error("This device does not support records.") + + data = rec.parsed_data + latest = data.last_clean + click.echo( + f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " + f"(cleaned {rec.total_clean_count} times)" + ) + click.echo(f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}") + click.echo("Execute `kasa vacuum records list` to list all records.") + + +@records_group.command(name="list") +@pass_dev_or_child +async def records_list(dev: Device) -> None: + """List all cleaning records.""" + if not (rec := dev.modules.get(Module.CleanRecords)): + error("This device does not support records.") + + data = rec.parsed_data + for record in data.records: + click.echo( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) + + +@vacuum.group(invoke_without_command=True, name="consumables") +@pass_dev_or_child +@click.pass_context +async def consumables(ctx: click.Context, dev: Device) -> None: + """List device consumables.""" + if not (cons := dev.modules.get(Module.Consumables)): + error("This device does not support consumables.") + + if not ctx.invoked_subcommand: + for c in cons.consumables.values(): + click.echo(f"{c.name} ({c.id}): {c.used} used, {c.remaining} remaining") + + +@consumables.command(name="reset") +@click.argument("consumable_id", required=True) +@pass_dev_or_child +async def reset_consumable(dev: Device, consumable_id: str) -> None: + """Reset the consumable used/remaining time.""" + cons = dev.modules[Module.Consumables] + + if consumable_id not in cons.consumables: + error( + f"Consumable {consumable_id} not found in " + f"device consumables: {', '.join(cons.consumables.keys())}." + ) + + await cons.reset_consumable(consumable_id) + + click.echo(f"Consumable {consumable_id} reset") diff --git a/kasa/cli/wifi.py b/kasa/cli/wifi.py index 924e83f1f..0fc7bdd62 100644 --- a/kasa/cli/wifi.py +++ b/kasa/cli/wifi.py @@ -6,6 +6,7 @@ from kasa import ( Device, + KasaException, ) from .common import ( @@ -15,8 +16,7 @@ @click.group() -@pass_dev -def wifi(dev) -> None: +def wifi() -> None: """Commands to control wifi settings.""" @@ -35,13 +35,23 @@ async def scan(dev): @wifi.command() @click.argument("ssid") -@click.option("--keytype", prompt=True) +@click.option( + "--keytype", + default="", + help="KeyType (Not needed for SmartCamDevice).", +) @click.option("--password", prompt=True, hide_input=True) @pass_dev async def join(dev: Device, ssid: str, password: str, keytype: str): """Join the given wifi network.""" echo(f"Asking the device to connect to {ssid}..") - res = await dev.wifi_join(ssid, password, keytype=keytype) + try: + res = await dev.wifi_join(ssid, password, keytype=keytype) + except KasaException as e: + if type(e) is KasaException: + echo(str(e)) + return + raise echo( f"Response: {res} - if the device is not able to join the network, " f"it will revert back to its previous state." diff --git a/kasa/credentials.py b/kasa/credentials.py index 2d6699994..3497b76aa 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -16,15 +16,17 @@ class Credentials: password: str = field(default="", repr=False) -def get_default_credentials(tuple: tuple[str, str]) -> Credentials: +def get_default_credentials(crdentials: tuple[str, str]) -> Credentials: """Return decoded default credentials.""" - un = base64.b64decode(tuple[0].encode()).decode() - pw = base64.b64decode(tuple[1].encode()).decode() + un = base64.b64decode(crdentials[0].encode()).decode() + pw = base64.b64decode(crdentials[1].encode()).decode() return Credentials(un, pw) DEFAULT_CREDENTIALS = { "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), + "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="), "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), + "TAPOCAMERA_LV3": ("YWRtaW4=", "VFBMMDc1NTI2NDYwNjAz"), } diff --git a/kasa/device.py b/kasa/device.py index 76d7a7c59..18dd53140 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,10 +6,10 @@ >>> from kasa import Discover >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.2", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.2", +... username="user@example.com", +... password="great_password" +... ) >>> Most devices can be turned on and off @@ -29,7 +29,7 @@ >>> dev.alias Bedroom Lamp Plug >>> dev.model -HS110(EU) +HS110 >>> dev.rssi -71 >>> dev.mac @@ -46,7 +46,8 @@ :ref:`modules ` that you can access via :attr:`~kasa.Device.modules`: >>> for module_name in dev.modules: ->>> print(module_name) +... print(module_name) +homekit Energy schedule usage @@ -80,11 +81,12 @@ added to the API. >>> for feature_name in dev.features: ->>> print(feature_name) +... print(feature_name) state rssi on_since reboot +... current_consumption consumption_today consumption_this_month @@ -107,7 +109,7 @@ import logging from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias @@ -136,22 +138,25 @@ class WifiNetwork: """Wifi network container.""" ssid: str - key_type: int + # This is available on both netif and on softaponboarding + key_type: int | None = None # These are available only on softaponboarding cipher_type: int | None = None - bssid: str | None = None channel: int | None = None + # These are available on softaponboarding, SMART, and SMARTCAM devices + bssid: str | None = None rssi: int | None = None - - # For SMART devices + # These are available on both SMART and SMARTCAM devices signal_level: int | None = None + auth: int | None = None + encryption: int | None = None _LOGGER = logging.getLogger(__name__) @dataclass -class _DeviceInfo: +class DeviceInfo: """Device Model Information.""" short_name: str @@ -161,7 +166,7 @@ class _DeviceInfo: device_type: DeviceType hardware_version: str firmware_version: str - firmware_build: str + firmware_build: str | None requires_auth: bool region: str | None @@ -208,7 +213,7 @@ def __init__( self.protocol: BaseProtocol = protocol or IotProtocol( transport=XorTransport(config=config or DeviceConfig(host=host)), ) - self._last_update: Any = None + self._last_update: dict[str, Any] = {} _LOGGER.debug("Initializing %s of type %s", host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using dict | None would require separate @@ -334,9 +339,21 @@ def model(self) -> str: """Returns the device model.""" @property + def region(self) -> str | None: + """Returns the device region.""" + return self.device_info.region + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return self._get_device_info(self._last_update, self._discovery_info) + + @staticmethod @abstractmethod - def _model_region(self) -> str: - """Return device full model name and region.""" + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> DeviceInfo: + """Get device info.""" @property @abstractmethod @@ -525,19 +542,52 @@ def _get_replacing_attr( return None + def _get_deprecated_callable_attribute(self, name: str) -> Any | None: + vals: dict[str, tuple[ModuleName, Callable[[Any], Any], str]] = { + "is_dimmable": ( + Module.Light, + lambda c: c.has_feature("brightness"), + 'light_module.has_feature("brightness")', + ), + "is_color": ( + Module.Light, + lambda c: c.has_feature("hsv"), + 'light_module.has_feature("hsv")', + ), + "is_variable_color_temp": ( + Module.Light, + lambda c: c.has_feature("color_temp"), + 'light_module.has_feature("color_temp")', + ), + "valid_temperature_range": ( + Module.Light, + lambda c: c._deprecated_valid_temperature_range(), + 'minimum and maximum value of get_feature("color_temp")', + ), + "has_effects": ( + Module.Light, + lambda c: Module.LightEffect in c._device.modules, + "Module.LightEffect in device.modules", + ), + } + if mod_call_msg := vals.get(name): + mod, call, msg = mod_call_msg + msg = f"{name} is deprecated, use: {msg} instead" + warn(msg, DeprecationWarning, stacklevel=2) + if (module := self.modules.get(mod)) is None: + raise AttributeError(f"Device has no attribute {name!r}") + return call(module) + + return None + _deprecated_other_attributes = { # light attributes - "is_color": (Module.Light, ["is_color"]), - "is_dimmable": (Module.Light, ["is_dimmable"]), - "is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]), "brightness": (Module.Light, ["brightness"]), "set_brightness": (Module.Light, ["set_brightness"]), "hsv": (Module.Light, ["hsv"]), "set_hsv": (Module.Light, ["set_hsv"]), "color_temp": (Module.Light, ["color_temp"]), "set_color_temp": (Module.Light, ["set_color_temp"]), - "valid_temperature_range": (Module.Light, ["valid_temperature_range"]), - "has_effects": (Module.Light, ["has_effects"]), "_deprecated_set_light_state": (Module.Light, ["has_effects"]), # led attributes "led": (Module.Led, ["led"]), @@ -576,6 +626,9 @@ def __getattr__(self, name: str) -> Any: msg = f"{name} is deprecated, use device_type property instead" warn(msg, DeprecationWarning, stacklevel=2) return self.device_type == dep_device_type_attr[1] + # callable + if (result := self._get_deprecated_callable_attribute(name)) is not None: + return result # Other deprecated attributes if (dep_attr := self._deprecated_other_attributes.get(name)) and ( (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) diff --git a/kasa/device_factory.py b/kasa/device_factory.py old mode 100755 new mode 100644 index d7ba5b532..ecb0d0a13 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -8,7 +8,7 @@ from .device import Device from .device_type import DeviceType -from .deviceconfig import DeviceConfig +from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily from .exceptions import KasaException, UnsupportedDeviceError from .iot import ( IotBulb, @@ -32,6 +32,8 @@ BaseTransport, KlapTransport, KlapTransportV2, + LinkieTransportV2, + SslTransport, XorTransport, ) from .transports.sslaestransport import SslAesTransport @@ -137,6 +139,8 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: DeviceType.Strip: IotStrip, DeviceType.WallSwitch: IotWallSwitch, DeviceType.LightStrip: IotLightStrip, + # Disabled until properly implemented + # DeviceType.Camera: IotCamera, } return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] @@ -155,8 +159,12 @@ def get_device_class_from_family( "SMART.KASAHUB": SmartDevice, "SMART.KASASWITCH": SmartDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice, + "SMART.TAPODOORBELL.HTTPS": SmartCamDevice, + "SMART.TAPOROBOVAC.HTTPS": SmartDevice, "IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTBULB": IotBulb, + # Disabled until properly implemented + # "IOT.IPCAMERA": IotCamera, } lookup_key = f"{device_type}{'.HTTPS' if https else ''}" if ( @@ -167,21 +175,55 @@ def get_device_class_from_family( _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice + if cls is not None: + _LOGGER.debug("Using %s for %s", cls.__name__, device_type) + return cls -def get_protocol( - config: DeviceConfig, -) -> BaseProtocol | None: - """Return the protocol from the connection name.""" - protocol_name = config.connection_type.device_family.value.split(".")[0] +def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | None: + """Return the protocol from the device config. + + For cameras and vacuums the device family is a simple mapping to + the protocol/transport. For other device types the transport varies + based on the discovery information. + + :param config: Device config to derive protocol + :param strict: Require exact match on encrypt type + """ + _LOGGER.debug("Finding protocol for %s", config.host) ctype = config.connection_type + protocol_name = ctype.device_family.value.split(".")[0] + _LOGGER.debug("Finding protocol for %s", ctype.device_family) + + if ctype.device_family in { + DeviceFamily.SmartIpCamera, + DeviceFamily.SmartTapoDoorbell, + }: + if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: + return None + return SmartCamProtocol(transport=SslAesTransport(config=config)) + + if ctype.device_family is DeviceFamily.IotIpCamera: + if strict and ctype.encryption_type is not DeviceEncryptionType.Xor: + return None + return IotProtocol(transport=LinkieTransportV2(config=config)) + + # Older FW used a different transport + if ( + ctype.device_family is DeviceFamily.SmartTapoRobovac + and ctype.encryption_type is DeviceEncryptionType.Aes + ): + return SmartProtocol(transport=SslTransport(config=config)) + protocol_transport_key = ( protocol_name + "." + ctype.encryption_type.value + (".HTTPS" if ctype.https else "") ) + + _LOGGER.debug("Finding transport for %s", protocol_transport_key) supported_device_protocols: dict[ str, tuple[type[BaseProtocol], type[BaseTransport]] ] = { @@ -189,6 +231,9 @@ def get_protocol( "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), + "SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2), + # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use + # https to distuingish from SmartProtocol devices "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), } if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): diff --git a/kasa/device_type.py b/kasa/device_type.py index b690f1f10..d39962179 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -21,6 +21,9 @@ class DeviceType(Enum): Hub = "hub" Fan = "fan" Thermostat = "thermostat" + Vacuum = "vacuum" + Chime = "chime" + Doorbell = "doorbell" Unknown = "unknown" @staticmethod diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 1156cf257..ff0fbf8fe 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -7,10 +7,10 @@ >>> from kasa import Discover, Device >>> device = await Discover.discover_single( ->>> "127.0.0.3", ->>> username="user@example.com", ->>> password="great_password", ->>> ) +... "127.0.0.3", +... username="user@example.com", +... password="great_password", +... ) >>> print(device.alias) # Alias is None because update() has not been called None @@ -20,7 +20,7 @@ {'host': '127.0.0.3', 'timeout': 5, 'credentials': {'username': 'user@example.com', \ 'password': 'great_password'}, 'connection_type'\ : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ -'https': False}, 'uses_http': True} +'https': False, 'http_port': 80}} >>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() @@ -69,6 +69,7 @@ class DeviceFamily(Enum): IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" IotSmartBulb = "IOT.SMARTBULB" + IotIpCamera = "IOT.IPCAMERA" SmartKasaPlug = "SMART.KASAPLUG" SmartKasaSwitch = "SMART.KASASWITCH" SmartTapoPlug = "SMART.TAPOPLUG" @@ -77,6 +78,9 @@ class DeviceFamily(Enum): SmartTapoHub = "SMART.TAPOHUB" SmartKasaHub = "SMART.KASAHUB" SmartIpCamera = "SMART.IPCAMERA" + SmartTapoRobovac = "SMART.TAPOROBOVAC" + SmartTapoChime = "SMART.TAPOCHIME" + SmartTapoDoorbell = "SMART.TAPODOORBELL" class _DeviceConfigBaseMixin(DataClassJSONMixin): @@ -96,13 +100,16 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): encryption_type: DeviceEncryptionType login_version: int | None = None https: bool = False + http_port: int | None = None @staticmethod def from_values( device_family: str, encryption_type: str, + *, login_version: int | None = None, https: bool | None = None, + http_port: int | None = None, ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: @@ -113,6 +120,7 @@ def from_values( DeviceEncryptionType(encryption_type), login_version, https, + http_port=http_port, ) except (ValueError, TypeError) as ex: raise KasaException( @@ -146,9 +154,12 @@ class DeviceConfig(_DeviceConfigBaseMixin): DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) ) - #: True if the device uses http. Consumers should retrieve rather than set this - #: in order to determine whether they should pass a custom http client if desired. - uses_http: bool = False + + @property + def uses_http(self) -> bool: + """True if the device uses http.""" + ctype = self.connection_type + return ctype.encryption_type is not DeviceEncryptionType.Xor or ctype.https #: Set a custom http_client for the device to use. http_client: ClientSession | None = field( diff --git a/kasa/discover.py b/kasa/discover.py index 75651b7ff..911c005c9 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,11 +1,11 @@ """Discover TPLink Smart Home devices. -The main entry point for this library is :func:`Discover.discover()`, +The main entry point for this library is :meth:`Discover.discover()`, which returns a dictionary of the found devices. The key is the IP address of the device and the value contains ready-to-use, SmartDevice-derived device object. -:func:`discover_single()` can be used to initialize a single device given its +:meth:`discover_single()` can be used to initialize a single device given its IP address. If the :class:`DeviceConfig` of the device is already known, you can initialize the corresponding device class directly without discovery. @@ -22,30 +22,30 @@ >>> >>> found_devices = await Discover.discover() >>> [dev.model for dev in found_devices.values()] -['KP303(UK)', 'HS110(EU)', 'L530E', 'KL430(US)', 'HS220(US)'] +['KP303', 'HS110', 'L530E', 'KL430', 'HS220', 'H200'] You can pass username and password for devices requiring authentication >>> devices = await Discover.discover( ->>> username="user@example.com", ->>> password="great_password", ->>> ) +... username="user@example.com", +... password="great_password", +... ) >>> print(len(devices)) -5 +6 You can also pass a :class:`kasa.Credentials` >>> creds = Credentials("user@example.com", "great_password") >>> devices = await Discover.discover(credentials=creds) >>> print(len(devices)) -5 +6 Discovery can also be targeted to a specific broadcast address instead of the default 255.255.255.255: >>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds) >>> print(len(found_devices)) -5 +6 Basic information is available on the device from the discovery broadcast response but it is important to call device.update() after discovery if you want to access @@ -61,21 +61,22 @@ It is also possible to pass a coroutine to be executed for each found device: >>> async def print_dev_info(dev): ->>> await dev.update() ->>> print(f"Discovered {dev.alias} (model: {dev.model})") +... await dev.update() +... print(f"Discovered {dev.alias} (model: {dev.model})") >>> >>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) -Discovered Bedroom Power Strip (model: KP303(UK)) -Discovered Bedroom Lamp Plug (model: HS110(EU)) +Discovered Bedroom Power Strip (model: KP303) +Discovered Bedroom Lamp Plug (model: HS110) Discovered Living Room Bulb (model: L530) -Discovered Bedroom Lightstrip (model: KL430(US)) -Discovered Living Room Dimmer Switch (model: HS220(US)) +Discovered Bedroom Lightstrip (model: KL430) +Discovered Living Room Dimmer Switch (model: HS220) +Discovered Tapo Hub (model: H200) Discovering a single device returns a kasa.Device object. >>> device = await Discover.discover_single("127.0.0.1", credentials=creds) >>> device.model -'KP303(UK)' +'KP303' """ @@ -99,6 +100,7 @@ Annotated, Any, NamedTuple, + TypedDict, cast, ) @@ -123,7 +125,7 @@ TimeoutError, UnsupportedDeviceError, ) -from kasa.iot.iotdevice import IotDevice +from kasa.iot.iotdevice import IotDevice, _extract_sys_info from kasa.json import DataClassJSONMixin from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads @@ -145,17 +147,46 @@ class ConnectAttempt(NamedTuple): protocol: type transport: type device: type + https: bool + + +class DiscoveredMeta(TypedDict): + """Meta info about discovery response.""" + + ip: str + port: int + + +class DiscoveredRaw(TypedDict): + """Try to connect attempt.""" + + meta: DiscoveredMeta + discovery_response: dict OnDiscoveredCallable = Callable[[Device], Coroutine] +OnDiscoveredRawCallable = Callable[[DiscoveredRaw], None] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] DeviceDict = dict[str, Device] +DECRYPTED_REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "connect_ssid": lambda x: "#MASKED_SSID#" if x else "", + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": lambda x: "REDACTED_" + x[9::], +} + NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { "device_id": lambda x: "REDACTED_" + x[9::], + "device_name": lambda x: "#MASKED_NAME#" if x else "", "owner": lambda x: "REDACTED_" + x[9::], "mac": mask_mac, + "master_device_id": lambda x: "REDACTED_" + x[9::], + "group_id": lambda x: "REDACTED_" + x[9::], + "group_name": lambda x: "I01BU0tFRF9TU0lEIw==", + "encrypt_info": lambda x: {**x, "key": "", "data": ""}, + "ip": lambda x: x, # don't redact but keep listed here for dump_devinfo + "decrypted_data": lambda x: redact_data(x, DECRYPTED_REDACTORS), } @@ -213,6 +244,7 @@ def __init__( self, *, on_discovered: OnDiscoveredCallable | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, target: str = "255.255.255.255", discovery_packets: int = 3, discovery_timeout: int = 5, @@ -232,11 +264,13 @@ def __init__( self.target = target self.target_1 = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) + self.target_3 = (target, Discover.DISCOVERY_PORT_3) self.discovered_devices = {} self.unsupported_device_exceptions: dict = {} self.invalid_device_exceptions: dict = {} self.on_unsupported = on_unsupported + self.on_discovered_raw = on_discovered_raw self.credentials = credentials self.timeout = timeout self.discovery_timeout = discovery_timeout @@ -300,6 +334,7 @@ async def do_discover(self) -> None: break self.transport.sendto(encrypted_req[4:], self.target_1) # type: ignore self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore + self.transport.sendto(aes_discovery_query, self.target_3) # type: ignore await asyncio.sleep(sleep_between_packets) def datagram_received( @@ -326,12 +361,22 @@ def datagram_received( config.timeout = self.timeout try: if port == self.discovery_port: - device = Discover._get_device_instance_legacy(data, config) - elif port == Discover.DISCOVERY_PORT_2: - config.uses_http = True - device = Discover._get_device_instance(data, config) + json_func = Discover._get_discovery_json_legacy + device_func = Discover._get_device_instance_legacy + elif port in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3): + json_func = Discover._get_discovery_json + device_func = Discover._get_device_instance else: return + info = json_func(data, ip) + if self.on_discovered_raw is not None: + self.on_discovered_raw( + { + "discovery_response": info, + "meta": {"ip": ip, "port": port}, + } + ) + device = device_func(info, config) except UnsupportedDeviceError as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex @@ -379,6 +424,7 @@ class Discover: } DISCOVERY_PORT_2 = 20002 + DISCOVERY_PORT_3 = 20004 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") _redact_data = True @@ -388,6 +434,7 @@ async def discover( *, target: str = "255.255.255.255", on_discovered: OnDiscoveredCallable | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, discovery_timeout: int = 5, discovery_packets: int = 3, interface: str | None = None, @@ -418,6 +465,8 @@ async def discover( :param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). :param on_discovered: coroutine to execute on discovery + :param on_discovered_raw: Optional callback once discovered json is loaded + before any attempt to deserialize it and create devices :param discovery_timeout: Seconds to wait for responses, defaults to 5 :param discovery_packets: Number of discovery packets to broadcast :param interface: Bind to specific interface @@ -440,6 +489,7 @@ async def discover( discovery_packets=discovery_packets, interface=interface, on_unsupported=on_unsupported, + on_discovered_raw=on_discovered_raw, credentials=credentials, timeout=timeout, discovery_timeout=discovery_timeout, @@ -452,7 +502,7 @@ async def discover( try: _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) await protocol.wait_for_discovery_to_complete() - except KasaException as ex: + except (KasaException, asyncio.CancelledError) as ex: for device in protocol.discovered_devices.values(): await device.protocol.close() raise ex @@ -473,6 +523,7 @@ async def discover_single( credentials: Credentials | None = None, username: str | None = None, password: str | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, on_unsupported: OnUnsupportedCallable | None = None, ) -> Device | None: """Discover a single device by the given IP address. @@ -490,6 +541,9 @@ async def discover_single( username and password are ignored if provided. :param username: Username for devices that require authentication :param password: Password for devices that require authentication + :param on_discovered_raw: Optional callback once discovered json is loaded + before any attempt to deserialize it and create devices + :param on_unsupported: Optional callback when unsupported devices are discovered :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -526,6 +580,7 @@ async def discover_single( credentials=credentials, timeout=timeout, discovery_timeout=discovery_timeout, + on_discovered_raw=on_discovered_raw, ), local_addr=("0.0.0.0", 0), # noqa: S104 ) @@ -583,22 +638,26 @@ async def try_connect_all( Device.Family.SmartTapoPlug, Device.Family.IotSmartPlugSwitch, Device.Family.SmartIpCamera, + Device.Family.SmartTapoRobovac, + Device.Family.IotIpCamera, } candidates: dict[ - tuple[type[BaseProtocol], type[BaseTransport], type[Device]], + tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool], tuple[BaseProtocol, DeviceConfig], ] = { - (type(protocol), type(protocol._transport), device_class): ( + (type(protocol), type(protocol._transport), device_class, https): ( protocol, config, ) for encrypt in Device.EncryptionType for device_family in main_device_families for https in (True, False) + for login_version in (None, 2) if ( conn_params := DeviceConnectionParameters( device_family=device_family, encryption_type=encrypt, + login_version=login_version, https=https, ) ) @@ -610,10 +669,9 @@ async def try_connect_all( port_override=port, credentials=credentials, http_client=http_client, - uses_http=encrypt is not Device.EncryptionType.Xor, ) ) - and (protocol := get_protocol(config)) + and (protocol := get_protocol(config, strict=True)) and ( device_class := get_device_class_from_family( device_family.value, https=https, require_exact=True @@ -623,9 +681,14 @@ async def try_connect_all( for key, val in candidates.items(): try: prot, config = val + _LOGGER.debug("Trying to connect with %s", prot.__class__.__name__) dev = await _connect(config, prot) - except Exception: - _LOGGER.debug("Unable to connect with %s", prot) + except Exception as ex: + _LOGGER.debug( + "Unable to connect with %s: %s", + prot.__class__.__name__, + ex, + ) if on_attempt: ca = tuple.__new__(ConnectAttempt, key) on_attempt(ca, False) @@ -633,6 +696,7 @@ async def try_connect_all( if on_attempt: ca = tuple.__new__(ConnectAttempt, key) on_attempt(ca, True) + _LOGGER.debug("Found working protocol %s", prot.__class__.__name__) return dev finally: await prot.close() @@ -643,7 +707,11 @@ def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: discovery_result = DiscoveryResult.from_dict(info["result"]) - https = discovery_result.mgt_encrypt_schm.is_support_https + https = ( + discovery_result.mgt_encrypt_schm.is_support_https + if discovery_result.mgt_encrypt_schm + else False + ) dev_class = get_device_class_from_family( discovery_result.device_type, https=https ) @@ -657,33 +725,45 @@ def _get_device_class(info: dict) -> type[Device]: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: - """Get SmartDevice from legacy 9999 response.""" + def _get_discovery_json_legacy(data: bytes, ip: str) -> dict: + """Get discovery json from legacy 9999 response.""" try: info = json_loads(XorEncryption.decrypt(data)) except Exception as ex: raise KasaException( - f"Unable to read response from device: {config.host}: {ex}" + f"Unable to read response from device: {ip}: {ex}" ) from ex + return info + @staticmethod + def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: + """Get IotDevice from legacy 9999 response.""" if _LOGGER.isEnabledFor(logging.DEBUG): data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device_class = cast(type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) - sys_info = info["system"]["get_sysinfo"] - if device_type := sys_info.get("mic_type", sys_info.get("type")): - config.connection_type = DeviceConnectionParameters.from_values( - device_family=device_type, - encryption_type=DeviceEncryptionType.Xor.value, - ) + sys_info = _extract_sys_info(info) + device_type = sys_info.get("mic_type", sys_info.get("type")) + if device_type is None: + raise UnsupportedDeviceError("type nor mic_type found in sysinfo response") + login_version = ( + sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None + ) + config.connection_type = DeviceConnectionParameters.from_values( + device_family=device_type, + encryption_type=DeviceEncryptionType.Xor.value, + https=device_type == "IOT.IPCAMERA", + login_version=login_version, + ) device.protocol = get_protocol(config) # type: ignore[assignment] device.update_from_discover_info(info) return device @staticmethod def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if TYPE_CHECKING: assert discovery_result.encrypt_info assert _AesDiscoveryQuery.keypair @@ -699,22 +779,80 @@ def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: session = AesEncyptionSession(key, iv) decrypted_data = session.decrypt(encrypted_data) - discovery_result.decrypted_data = json_loads(decrypted_data) + result = json_loads(decrypted_data) + if debug_enabled: + data = ( + redact_data(result, DECRYPTED_REDACTORS) + if Discover._redact_data + else result + ) + _LOGGER.debug( + "Decrypted encrypt_info for %s: %s", + discovery_result.ip, + pf(data), + ) + discovery_result.decrypted_data = result @staticmethod - def _get_device_instance( - data: bytes, - config: DeviceConfig, - ) -> Device: - """Get SmartDevice from the new 20002 response.""" - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + def _get_discovery_json(data: bytes, ip: str) -> dict: + """Get discovery json from the new 20002 response.""" try: info = json_loads(data[16:]) except Exception as ex: - _LOGGER.debug("Got invalid response from device %s: %s", config.host, data) + _LOGGER.debug("Got invalid response from device %s: %s", ip, data) raise KasaException( - f"Unable to read response from device: {config.host}: {ex}" + f"Unable to read response from device: {ip}: {ex}" ) from ex + return info + + @staticmethod + def _get_connection_parameters( + discovery_result: DiscoveryResult, + ) -> DeviceConnectionParameters: + """Get connection parameters from the discovery result.""" + type_ = discovery_result.device_type + if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None: + raise UnsupportedDeviceError( + f"Unsupported device {discovery_result.ip} of type {type_} " + "with no mgt_encrypt_schm", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) + + if not (encrypt_type := encrypt_schm.encrypt_type) and ( + encrypt_info := discovery_result.encrypt_info + ): + encrypt_type = encrypt_info.sym_schm + + if not (login_version := encrypt_schm.lv) and ( + et := discovery_result.encrypt_type + ): + # Known encrypt types are ["1","2"] and ["3"] + # Reuse the login_version attribute to pass the max to transport + login_version = max([int(i) for i in et]) + + if not encrypt_type: + raise UnsupportedDeviceError( + f"Unsupported device {discovery_result.ip} of type {type_} " + + "with no encryption type", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) + return DeviceConnectionParameters.from_values( + type_, + encrypt_type, + login_version=login_version, + https=encrypt_schm.is_support_https, + http_port=encrypt_schm.http_port, + ) + + @staticmethod + def _get_device_instance( + info: dict, + config: DeviceConfig, + ) -> Device: + """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: discovery_result = DiscoveryResult.from_dict(info["result"]) @@ -743,43 +881,26 @@ def _get_device_instance( Discover._decrypt_discovery_data(discovery_result) except Exception: _LOGGER.exception( - "Unable to decrypt discovery data %s: %s", config.host, data + "Unable to decrypt discovery data %s: %s", + config.host, + redact_data(info, NEW_DISCOVERY_REDACTORS), ) - type_ = discovery_result.device_type - encrypt_schm = discovery_result.mgt_encrypt_schm - try: - if not (encrypt_type := encrypt_schm.encrypt_type) and ( - encrypt_info := discovery_result.encrypt_info - ): - encrypt_type = encrypt_info.sym_schm - - if not encrypt_type: - raise UnsupportedDeviceError( - f"Unsupported device {config.host} of type {type_} " - + "with no encryption type", - discovery_result=discovery_result.to_dict(), - host=config.host, - ) - config.connection_type = DeviceConnectionParameters.from_values( - type_, - encrypt_type, - discovery_result.mgt_encrypt_schm.lv, - discovery_result.mgt_encrypt_schm.is_support_https, - ) + conn_params = Discover._get_connection_parameters(discovery_result) + config.connection_type = conn_params except KasaException as ex: + if isinstance(ex, UnsupportedDeviceError): + raise raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " - + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", + + f"with encrypt_scheme {discovery_result.mgt_encrypt_schm}", discovery_result=discovery_result.to_dict(), host=config.host, ) from ex if ( - device_class := get_device_class_from_family( - type_, https=encrypt_schm.is_support_https - ) + device_class := get_device_class_from_family(type_, https=conn_params.https) ) is None: _LOGGER.debug("Got unsupported device type: %s", type_) raise UnsupportedDeviceError( @@ -854,7 +975,7 @@ class DiscoveryResult(_DiscoveryBaseMixin): device_id: str ip: str mac: str - mgt_encrypt_schm: EncryptionScheme + mgt_encrypt_schm: EncryptionScheme | None = None device_name: str | None = None encrypt_info: EncryptionInfo | None = None encrypt_type: list[str] | None = None diff --git a/kasa/exceptions.py b/kasa/exceptions.py index a0ecbf8fe..1c764ad7a 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -127,11 +127,14 @@ def from_int(value: int) -> SmartErrorCode: DST_ERROR = -2301 DST_SAVE_ERROR = -2302 + VACUUM_BATTERY_LOW = -3001 + SYSTEM_ERROR = -40101 INVALID_ARGUMENTS = -40209 # Camera error codes SESSION_EXPIRED = -40401 + BAD_USERNAME = -40411 # determined from testing HOMEKIT_LOGIN_FAIL = -40412 DEVICE_BLOCKED = -40404 DEVICE_FACTORY = -40405 diff --git a/kasa/feature.py b/kasa/feature.py index d747338da..f1a72d087 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -6,10 +6,10 @@ >>> from kasa import Discover, Module >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.3", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.3", +... username="user@example.com", +... password="great_password" +... ) >>> await dev.update() >>> print(dev.alias) Living Room Bulb @@ -18,14 +18,14 @@ to the API: >>> for feature_id, feature in dev.features.items(): ->>> print(f"{feature.name} ({feature_id}): {feature.value}") +... print(f"{feature.name} ({feature_id}): {feature.value}") Device ID (device_id): 0000000000000000000000000000000000000000 State (state): True Signal Level (signal_level): 2 RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# -Overheated (overheated): False Reboot (reboot): +Device time (device_time): 2024-02-23 02:40:15+01:00 Brightness (brightness): 100 Cloud connection (cloud_connection): True HSV (hsv): HSV(hue=0, saturation=100, value=100) @@ -39,12 +39,12 @@ Light preset (light_preset): Not set Smooth transition on (smooth_transition_on): 2 Smooth transition off (smooth_transition_off): 2 -Device time (device_time): 2024-02-23 02:40:15+01:00 +Overheated (overheated): False To see whether a device supports a feature, check for the existence of it: >>> if feature := dev.features.get("brightness"): ->>> print(feature.value) +... print(feature.value) 100 You can update the value of a feature @@ -76,6 +76,7 @@ if TYPE_CHECKING: from .device import Device + from .module import Module _LOGGER = logging.getLogger(__name__) @@ -142,7 +143,7 @@ class Category(Enum): #: Callable coroutine or name of the method that allows changing the value attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None #: Container storing the data, this overrides 'device' for getters - container: Any = None + container: Device | Module | None = None #: Icon suggestion icon: str | None = None #: Attribute containing the name of the unit getter property. @@ -255,7 +256,7 @@ async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: elif self.type == Feature.Type.Choice: # noqa: SIM102 if not self.choices or value not in self.choices: raise ValueError( - f"Unexpected value for {self.name}: {value}" + f"Unexpected value for {self.name}: '{value}'" f" - allowed: {self.choices}" ) @@ -278,7 +279,18 @@ def __repr__(self) -> str: return f"Unable to read value ({self.id}): {ex}" if self.type == Feature.Type.Choice: - if not isinstance(choices, list) or value not in choices: + if not isinstance(choices, list): + _LOGGER.error( + "Choices are not properly defined for %s (%s). Type: <%s> Value: %s", # noqa: E501 + self.name, + self.id, + type(choices), + choices, + ) + return f"{self.name} ({self.id}): improperly defined choice set." + if (value not in choices) and ( + isinstance(value, Enum) and value.name not in choices + ): _LOGGER.warning( "Invalid value for for choice %s (%s): %s not in %s", self.name, @@ -290,14 +302,24 @@ def __repr__(self) -> str: f"{self.name} ({self.id}): invalid value '{value}' not in {choices}" ) value = " ".join( - [f"*{choice}*" if choice == value else choice for choice in choices] + [ + f"*{choice}*" + if choice == value + or (isinstance(value, Enum) and choice == value.name) + else f"{choice}" + for choice in choices + ] ) if self.precision_hint is not None and isinstance(value, float): value = round(value, self.precision_hint) + if isinstance(value, Enum): + value = repr(value) s = f"{self.name} ({self.id}): {value}" - if self.unit is not None: - s += f" {self.unit}" + if (unit := self.unit) is not None: + if isinstance(unit, Enum): + unit = repr(unit) + s += f" {unit}" if self.type == Feature.Type.Number: s += f" (range: {self.minimum_value}-{self.maximum_value})" diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 87e3626a3..31d8dfbb6 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -113,10 +113,23 @@ async def post( ssl=ssl, ) async with resp: - if resp.status == 200: - response_data = await resp.read() - if return_json: + response_data = await resp.read() + + if resp.status == 200: + if return_json: + response_data = json_loads(response_data.decode()) + else: + _LOGGER.debug( + "Device %s received status code %s with response %s", + self._config.host, + resp.status, + str(response_data), + ) + if response_data and return_json: + try: response_data = json_loads(response_data.decode()) + except Exception: + _LOGGER.debug("Device %s response could not be parsed as json") except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: if not self._wait_between_requests: diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index e5fd4caee..ac5e00da0 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,7 @@ """Package for interfaces.""" +from .alarm import Alarm +from .childsetup import ChildSetup from .energy import Energy from .fan import Fan from .led import Led @@ -10,6 +12,8 @@ from .time import Time __all__ = [ + "Alarm", + "ChildSetup", "Fan", "Energy", "Led", diff --git a/kasa/interfaces/alarm.py b/kasa/interfaces/alarm.py new file mode 100644 index 000000000..1a50b1ef7 --- /dev/null +++ b/kasa/interfaces/alarm.py @@ -0,0 +1,75 @@ +"""Module for base alarm module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Annotated + +from ..module import FeatureAttribute, Module + + +class Alarm(Module, ABC): + """Base interface to represent an alarm module.""" + + @property + @abstractmethod + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: + """Return current alarm sound.""" + + @abstractmethod + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + + @property + @abstractmethod + def alarm_sounds(self) -> list[str]: + """Return list of available alarm sounds.""" + + @property + @abstractmethod + def alarm_volume(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm volume.""" + + @abstractmethod + async def set_alarm_volume( + self, volume: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm volume.""" + + @property + @abstractmethod + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm duration.""" + + @abstractmethod + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm duration.""" + + @property + @abstractmethod + def active(self) -> bool: + """Return true if alarm is active.""" + + @abstractmethod + async def play( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + + @abstractmethod + async def stop(self) -> dict: + """Stop alarm.""" diff --git a/kasa/interfaces/childsetup.py b/kasa/interfaces/childsetup.py new file mode 100644 index 000000000..9de07583c --- /dev/null +++ b/kasa/interfaces/childsetup.py @@ -0,0 +1,70 @@ +"""Module for childsetup interface. + +The childsetup module allows pairing and unpairing of supported child device types to +hubs. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +... "127.0.0.6", +... username="user@example.com", +... password="great_password" +... ) +>>> await dev.update() +>>> print(dev.alias) +Tapo Hub + +>>> childsetup = dev.modules[Module.ChildSetup] +>>> childsetup.supported_categories +['camera', 'subg.trv', 'subg.trigger', 'subg.plugswitch'] + +Put child devices in pairing mode. +The hub will pair with all supported devices in pairing mode: + +>>> added = await childsetup.pair() +>>> added +[{'device_id': 'SCRUBBED_CHILD_DEVICE_ID_5', 'category': 'subg.trigger.button', \ +'device_model': 'S200B', 'name': 'I01BU0tFRF9OQU1FIw===='}] + +>>> for child in dev.children: +... print(f"{child.device_id} - {child.model}") +SCRUBBED_CHILD_DEVICE_ID_1 - T310 +SCRUBBED_CHILD_DEVICE_ID_2 - T315 +SCRUBBED_CHILD_DEVICE_ID_3 - T110 +SCRUBBED_CHILD_DEVICE_ID_4 - S200B +SCRUBBED_CHILD_DEVICE_ID_5 - S200B + +Unpair with the child `device_id`: + +>>> await childsetup.unpair("SCRUBBED_CHILD_DEVICE_ID_4") +>>> for child in dev.children: +... print(f"{child.device_id} - {child.model}") +SCRUBBED_CHILD_DEVICE_ID_1 - T310 +SCRUBBED_CHILD_DEVICE_ID_2 - T315 +SCRUBBED_CHILD_DEVICE_ID_3 - T110 +SCRUBBED_CHILD_DEVICE_ID_5 - S200B + +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..module import Module + + +class ChildSetup(Module, ABC): + """Interface for child setup on hubs.""" + + @property + @abstractmethod + def supported_categories(self) -> list[str]: + """Supported child device categories.""" + + @abstractmethod + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair them.""" + + @abstractmethod + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index c57a3ed80..b6cc203fa 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -28,7 +28,7 @@ class ModuleFeature(IntFlag): _supported: ModuleFeature = ModuleFeature(0) - def supports(self, module_feature: ModuleFeature) -> bool: + def supports(self, module_feature: Energy.ModuleFeature) -> bool: """Return True if module supports the feature.""" return module_feature in self._supported diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 1d99f846c..1eafe8c5c 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -3,10 +3,10 @@ >>> from kasa import Discover, Module >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.3", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.3", +... username="user@example.com", +... password="great_password" +... ) >>> await dev.update() >>> print(dev.alias) Living Room Bulb @@ -23,13 +23,13 @@ >>> light = dev.modules[Module.Light] -You can use the ``is_``-prefixed properties to check for supported features: +You can use the ``has_feature()`` method to check for supported features: ->>> light.is_dimmable +>>> light.has_feature("brightness") True ->>> light.is_color +>>> light.has_feature("hsv") True ->>> light.is_variable_color_temp +>>> light.has_feature("color_temp") True All known bulbs support changing the brightness: @@ -43,8 +43,9 @@ Bulbs supporting color temperature can be queried for the supported range: ->>> light.valid_temperature_range -ColorTempRange(min=2500, max=6500) +>>> if color_temp_feature := light.get_feature("color_temp"): +... print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}") +2500, 6500 >>> await light.set_color_temp(3000) >>> await dev.update() >>> light.color_temp @@ -64,8 +65,10 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Annotated, NamedTuple +from typing import TYPE_CHECKING, Annotated, Any, NamedTuple +from warnings import warn +from ..exceptions import KasaException from ..module import FeatureAttribute, Module @@ -99,34 +102,6 @@ class HSV(NamedTuple): class Light(Module, ABC): """Base class for TP-Link Light.""" - @property - @abstractmethod - def is_dimmable(self) -> bool: - """Whether the light supports brightness changes.""" - - @property - @abstractmethod - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - - @property - @abstractmethod - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - - @property - @abstractmethod - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - - @property - @abstractmethod - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - @property @abstractmethod def hsv(self) -> Annotated[HSV, FeatureAttribute()]: @@ -196,3 +171,44 @@ def state(self) -> LightState: @abstractmethod async def set_state(self, state: LightState) -> dict: """Set the light state.""" + + def _deprecated_valid_temperature_range(self) -> ColorTempRange: + if not (temp := self.get_feature("color_temp")): + raise KasaException("Color temperature not supported") + return ColorTempRange(temp.minimum_value, temp.maximum_value) + + def _deprecated_attributes(self, dep_name: str) -> str | None: + map: dict[str, str] = { + "is_color": "hsv", + "is_dimmable": "brightness", + "is_variable_color_temp": "color_temp", + } + return map.get(dep_name) + + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + if name == "valid_temperature_range": + msg = ( + "valid_temperature_range is deprecated, use " + 'get_feature("color_temp") minimum_value ' + " and maximum_value instead" + ) + warn(msg, DeprecationWarning, stacklevel=2) + res = self._deprecated_valid_temperature_range() + return res + + if name == "has_effects": + msg = ( + "has_effects is deprecated, check `Module.LightEffect " + "in device.modules` instead" + ) + warn(msg, DeprecationWarning, stacklevel=2) + return Module.LightEffect in self._device.modules + + if attr := self._deprecated_attributes(name): + msg = f'{name} is deprecated, use has_feature("{attr}") instead' + warn(msg, DeprecationWarning, stacklevel=2) + return self.has_feature(attr) + + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py index 9a69f2d09..28dcc303c 100644 --- a/kasa/interfaces/lighteffect.py +++ b/kasa/interfaces/lighteffect.py @@ -3,18 +3,17 @@ >>> from kasa import Discover, Module, LightState >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.3", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.3", +... username="user@example.com", +... password="great_password" +... ) >>> await dev.update() >>> print(dev.alias) Living Room Bulb Light effects are accessed via the LightPreset module. To list available presets ->>> if dev.modules[Module.Light].has_effects: ->>> light_effect = dev.modules[Module.LightEffect] +>>> light_effect = dev.modules[Module.LightEffect] >>> light_effect.effect_list ['Off', 'Party', 'Relax'] @@ -33,8 +32,8 @@ If the device supports it you can set custom effects: >>> if light_effect.has_custom_effects: ->>> effect_list = { "brightness", 50 } ->>> await light_effect.set_custom_effect(effect_list) +... effect_list = {"brightness": 50} +... await light_effect.set_custom_effect(effect_list) >>> light_effect.has_custom_effects # The device in this examples does not support \ custom effects False @@ -52,6 +51,7 @@ class LightEffect(Module, ABC): """Interface to represent a light effect module.""" LIGHT_EFFECTS_OFF = "Off" + LIGHT_EFFECTS_UNNAMED_CUSTOM = "Custom" def _initialize_features(self) -> None: """Initialize features.""" @@ -78,7 +78,7 @@ def has_custom_effects(self) -> bool: @property @abstractmethod def effect(self) -> str: - """Return effect state or name.""" + """Return effect name.""" @property @abstractmethod diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py index 586671e70..fd8508f89 100644 --- a/kasa/interfaces/lightpreset.py +++ b/kasa/interfaces/lightpreset.py @@ -3,10 +3,10 @@ >>> from kasa import Discover, Module, LightState >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.3", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.3", +... username="user@example.com", +... password="great_password" +... ) >>> await dev.update() >>> print(dev.alias) Living Room Bulb @@ -48,9 +48,9 @@ You can save a new preset state if the device supports it: >>> if light_preset.has_save_preset: ->>> new_preset_state = LightState(light_on=True, brightness=75, hue=0,\ +... new_preset_state = LightState(light_on=True, brightness=75, hue=0,\ saturation=100, color_temp=2700, transition=None) ->>> await light_preset.save_preset("Light preset 1", new_preset_state) +... await light_preset.save_preset("Light preset 1", new_preset_state) >>> await dev.update() >>> light_preset.preset # Saving updates the preset state for the preset, it does not \ set the preset diff --git a/kasa/interfaces/thermostat.py b/kasa/interfaces/thermostat.py index de7831b06..1d2ed28b2 100644 --- a/kasa/interfaces/thermostat.py +++ b/kasa/interfaces/thermostat.py @@ -15,7 +15,9 @@ class ThermostatState(Enum): Heating = "heating" Calibrating = "progress_calibration" Idle = "idle" + Hold = "hold_on" Off = "off" + Shutdown = "shutdown" Unknown = "unknown" diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py index 536679ca3..3b5b01c64 100644 --- a/kasa/iot/__init__.py +++ b/kasa/iot/__init__.py @@ -1,6 +1,7 @@ """Package for supporting legacy kasa devices.""" from .iotbulb import IotBulb +from .iotcamera import IotCamera from .iotdevice import IotDevice from .iotdimmer import IotDimmer from .iotlightstrip import IotLightStrip @@ -15,4 +16,5 @@ "IotDimmer", "IotLightStrip", "IotWallSwitch", + "IotCamera", ] diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index cb2e858cd..33c7a2bed 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -90,6 +90,7 @@ class TurnOnBehaviors(DataClassDictMixin): "KL135": ColorTempRange(2500, 9000), r"KL120\(EU\)": ColorTempRange(2700, 6500), r"KL120\(US\)": ColorTempRange(2700, 5000), + r"KL400L5": ColorTempRange(2500, 9000), r"KL430": ColorTempRange(2500, 9000), } @@ -102,12 +103,12 @@ class TurnOnBehaviors(DataClassDictMixin): class IotBulb(IotDevice): r"""Representation of a TP-Link Smart Bulb. - To initialize, you have to await :func:`update()` at least once. + To initialize, you have to await :meth:`update()` at least once. This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, which will not change the cached values, - so you must await :func:`update()` to fetch updates values from the device. + so you must await :meth:`update()` to fetch updated values from the device. Errors reported by the device are raised as :class:`KasaException `, @@ -118,7 +119,7 @@ class IotBulb(IotDevice): >>> bulb = IotBulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) - Bulb2 + Bedroom Bulb Bulbs, like any other supported devices, can be turned on and off: @@ -128,72 +129,82 @@ class IotBulb(IotDevice): >>> print(bulb.is_on) True - You can use the ``is_``-prefixed properties to check for supported features: + Get the light module to interact with light-specific features: - >>> bulb.is_dimmable + >>> light = bulb.modules[Module.Light] + + You can use the :meth:`~kasa.module.Module.has_feature` method to check for supported light features: + + >>> light.has_feature("brightness") True - >>> bulb.is_color + >>> light.has_feature("hsv") True - >>> bulb.is_variable_color_temp + >>> light.has_feature("color_temp") True All known bulbs support changing the brightness: - >>> bulb.brightness + >>> light.brightness 30 - >>> asyncio.run(bulb.set_brightness(50)) + >>> asyncio.run(light.set_brightness(50)) >>> asyncio.run(bulb.update()) - >>> bulb.brightness + >>> light.brightness 50 Bulbs supporting color temperature can be queried for the supported range: - >>> bulb.valid_temperature_range - ColorTempRange(min=2500, max=9000) - >>> asyncio.run(bulb.set_color_temp(3000)) + >>> if color_temp_feature := light.get_feature("color_temp"): + ... print( + ... f"{color_temp_feature.minimum_value}, " + ... f"{color_temp_feature.maximum_value}" + ... ) + 2500, 9000 + >>> asyncio.run(light.set_color_temp(3000)) >>> asyncio.run(bulb.update()) - >>> bulb.color_temp + >>> light.color_temp 3000 Color bulbs can be adjusted by passing hue, saturation and value: - >>> asyncio.run(bulb.set_hsv(180, 100, 80)) + >>> asyncio.run(light.set_hsv(180, 100, 80)) >>> asyncio.run(bulb.update()) - >>> bulb.hsv + >>> light.hsv HSV(hue=180, saturation=100, value=80) If you don't want to use the default transitions, you can pass `transition` in milliseconds. - All methods changing the state of the device support this parameter: + All methods changing the light state support this parameter: - * :func:`turn_on` - * :func:`turn_off` - * :func:`set_hsv` - * :func:`set_color_temp` - * :func:`set_brightness` + * :meth:`turn_on` + * :meth:`turn_off` + * :meth:`set_hsv` + * :meth:`set_color_temp` + * :meth:`set_brightness` Light strips (e.g., KL420L5) do not support this feature, but silently ignore the parameter. The following changes the brightness over a period of 10 seconds: - >>> asyncio.run(bulb.set_brightness(100, transition=10_000)) + >>> asyncio.run(light.set_brightness(100, transition=10_000)) - Bulb configuration presets can be accessed using the :func:`presets` property: + Bulb configuration presets can be accessed using the light preset module: - >>> [ preset.to_dict() for preset in bulb.presets } - [{'brightness': 50, 'hue': 0, 'saturation': 0, 'color_temp': 2700, 'index': 0}, {'brightness': 100, 'hue': 0, 'saturation': 75, 'color_temp': 0, 'index': 1}, {'brightness': 100, 'hue': 120, 'saturation': 75, 'color_temp': 0, 'index': 2}, {'brightness': 100, 'hue': 240, 'saturation': 75, 'color_temp': 0, 'index': 3}] + >>> light_preset = bulb.modules[Module.LightPreset] + >>> light_preset.preset_states_list[0] + IotLightPreset(light_on=None, brightness=50, hue=0, saturation=0, color_temp=2700, transition=None) - To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` - instance to :func:`save_preset` method: + To modify an existing preset, update one of the entries in + ``preset_states_list`` and pass it to + :meth:`~kasa.interfaces.lightpreset.LightPreset.save_preset`: - >>> preset = bulb.presets[0] + >>> preset = light_preset.preset_states_list[0] >>> preset.brightness 50 >>> preset.brightness = 100 - >>> asyncio.run(bulb.save_preset(preset)) + >>> asyncio.run(light_preset.save_preset("Light preset 1", preset)) >>> asyncio.run(bulb.update()) - >>> bulb.presets[0].brightness - 100 + >>> light_preset.preset_states_list[0] + IotLightPreset(light_on=None, brightness=100, hue=0, saturation=0, color_temp=2700, transition=None) """ # noqa: E501 diff --git a/kasa/iot/iotcamera.py b/kasa/iot/iotcamera.py new file mode 100644 index 000000000..8965948ce --- /dev/null +++ b/kasa/iot/iotcamera.py @@ -0,0 +1,42 @@ +"""Module for cameras.""" + +from __future__ import annotations + +import logging +from datetime import datetime, tzinfo + +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocols import BaseProtocol +from .iotdevice import IotDevice + +_LOGGER = logging.getLogger(__name__) + + +class IotCamera(IotDevice): + """Representation of a TP-Link Camera.""" + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, + ) -> None: + super().__init__(host=host, config=config, protocol=protocol) + self._device_type = DeviceType.Camera + + @property + def time(self) -> datetime: + """Get the camera's time.""" + return datetime.fromtimestamp(self.sys_info["system_time"]) + + @property + def timezone(self) -> tzinfo: + """Get the camera's timezone.""" + return None # type: ignore + + @property # type: ignore + def is_on(self) -> bool: + """Return whether device is on.""" + return True diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index f23ebc8bd..a74e44c40 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -22,16 +22,16 @@ from typing import TYPE_CHECKING, Any, cast from warnings import warn -from ..device import Device, WifiNetwork, _DeviceInfo +from ..device import Device, DeviceInfo, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..exceptions import KasaException +from ..exceptions import KasaException, UnsupportedDeviceError from ..feature import Feature from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..protocols import BaseProtocol from .iotmodule import IotModule, merge -from .modules import Emeter +from .modules import Emeter, HomeKit _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any: @functools.wraps(f) async def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and ( + if not self._last_update and ( self._sys_info is None or f.__name__ not in self._sys_info ): raise KasaException("You need to await update() to access the data") @@ -54,7 +54,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: @functools.wraps(f) def wrapped(*args: Any, **kwargs: Any) -> Any: self = args[0] - if self._last_update is None and ( + if not self._last_update and ( self._sys_info is None or f.__name__ not in self._sys_info ): raise KasaException("You need to await update() to access the data") @@ -70,6 +70,16 @@ def _parse_features(features: str) -> set[str]: return set(features.split(":")) +def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]: + """Return the system info structure.""" + sysinfo_default = info.get("system", {}).get("get_sysinfo", {}) + sysinfo_nest = sysinfo_default.get("system", {}) + + if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict): + return sysinfo_nest + return sysinfo_default + + class IotDevice(Device): """Base class for all supported device types. @@ -82,7 +92,7 @@ class IotDevice(Device): * :class:`IotDimmer` * :class:`IotLightStrip` - To initialize, you have to await :func:`update()` at least once. + To initialize, you have to await :meth:`update()` at least once. This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, @@ -102,7 +112,7 @@ class IotDevice(Device): >>> dev.alias Bedroom Lamp Plug >>> dev.model - HS110(EU) + HS110 >>> dev.rssi -71 >>> dev.mac @@ -300,18 +310,18 @@ async def update(self, update_children: bool = True) -> None: # If this is the initial update, check only for the sysinfo # This is necessary as some devices crash on unexpected modules # See #105, #120, #161 - if self._last_update is None: + if not self._last_update: _LOGGER.debug("Performing the initial update to obtain sysinfo") response = await self.protocol.query(req) self._last_update = response - self._set_sys_info(response["system"]["get_sysinfo"]) + self._set_sys_info(_extract_sys_info(response)) if not self._modules: await self._initialize_modules() await self._modular_update(req) - self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + self._set_sys_info(_extract_sys_info(self._last_update)) for module in self._modules.values(): await module._post_update_hook() @@ -320,6 +330,8 @@ async def update(self, update_children: bool = True) -> None: async def _initialize_modules(self) -> None: """Initialize modules not added in init.""" + self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit")) + if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" @@ -442,7 +454,9 @@ def update_from_discover_info(self, info: dict[str, Any]) -> None: # This allows setting of some info properties directly # from partial discovery info that will then be found # by the requires_update decorator - self._set_sys_info(info) + discovery_model = info["device_model"] + no_region_model, _, _ = discovery_model.partition("(") + self._set_sys_info({**info, "model": no_region_model}) def _set_sys_info(self, sys_info: dict[str, Any]) -> None: """Set sys_info.""" @@ -461,18 +475,13 @@ class itself as @requires_update will be affected for other properties. """ return self._sys_info # type: ignore - @property # type: ignore - @requires_update - def model(self) -> str: - """Return device model.""" - sys_info = self._sys_info - return str(sys_info["model"]) - @property @requires_update - def _model_region(self) -> str: - """Return device full model name and region.""" - return self.model + def model(self) -> str: + """Returns the device model.""" + if self._last_update: + return self.device_info.short_name + return self._sys_info["model"] @property # type: ignore def alias(self) -> str | None: @@ -679,6 +688,9 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "3") -> dict: async def _join(target: str, payload: dict) -> dict: return await self._query_helper(target, "set_stainfo", payload) + if not keytype: + raise KasaException("KeyType is required for this device.") + payload = {"ssid": ssid, "password": password, "key_type": int(keytype)} try: return await _join("netif", payload) @@ -705,10 +717,13 @@ def internal_state(self) -> Any: @staticmethod def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: """Find SmartDevice subclass for device described by passed data.""" + if "system" in info.get("system", {}).get("get_sysinfo", {}): + return DeviceType.Camera + if "system" not in info or "get_sysinfo" not in info["system"]: raise KasaException("No 'system' or 'get_sysinfo' in response") - sysinfo: dict[str, Any] = info["system"]["get_sysinfo"] + sysinfo: dict[str, Any] = _extract_sys_info(info) type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: raise KasaException("Unable to find the device type field!") @@ -728,15 +743,16 @@ def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: return DeviceType.LightStrip return DeviceType.Bulb + _LOGGER.warning("Unknown device type %s, falling back to plug", type_) return DeviceType.Plug @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None - ) -> _DeviceInfo: + ) -> DeviceInfo: """Get model information for a device.""" - sys_info = info["system"]["get_sysinfo"] + sys_info = _extract_sys_info(info) # Get model and region info region = None @@ -747,12 +763,18 @@ def _get_device_info( # Get other info device_family = sys_info.get("type", sys_info.get("mic_type")) + if device_family is None: + raise UnsupportedDeviceError("type nor mic_type found in sysinfo response") + device_type = IotDevice._get_device_type_from_sys_info(info) fw_version_full = sys_info["sw_ver"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) - return _DeviceInfo( + return DeviceInfo( short_name=long_name, long_name=long_name, brand="kasa", diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 3960e641b..4338a068b 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -11,7 +11,7 @@ from ..protocols import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Light, Motion +from .modules import AmbientLight, Dimmer, Light, Motion class ButtonAction(Enum): @@ -41,14 +41,14 @@ class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for - adjusting the brightness. This class extends :class:`SmartPlug` interface. + adjusting the brightness. This class extends :class:`IotPlug` interface. - To initialize, you have to await :func:`update()` at least once. + To initialize, you have to await :meth:`update()` at least once. This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, which will not change the cached values, - but you must await :func:`update()` separately. + but you must await :meth:`update()` separately. Errors reported by the device are raised as :class:`KasaException`\s, and should be handled by the user of the library. @@ -65,7 +65,7 @@ class IotDimmer(IotPlug): >>> dimmer.brightness 50 - Refer to :class:`SmartPlug` for the full API. + Refer to :class:`IotPlug` for the full API. """ DIMMER_SERVICE = "smartlife.iot.dimmer" @@ -87,6 +87,7 @@ async def _initialize_modules(self) -> None: # TODO: need to be figured out what's the best approach to detect support self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.IotDimmer, Dimmer(self, "smartlife.iot.dimmer")) self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @@ -115,9 +116,7 @@ async def _set_brightness( raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): - raise ValueError( - "Brightness must be integer, " "not of %s.", type(brightness) - ) + raise ValueError("Brightness must be integer, not of %s.", type(brightness)) if not 0 <= brightness <= 100: raise ValueError( diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index f4107b3c1..516b5d92f 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -16,7 +16,7 @@ class IotLightStrip(IotBulb): Light strips work similarly to bulbs, but use a different service for controlling, and expose some extra information (such as length and active effect). - This class extends :class:`SmartBulb` interface. + This class extends :class:`IotBulb` interface. Examples: >>> import asyncio @@ -41,7 +41,7 @@ class IotLightStrip(IotBulb): feel free to find out how to control them and create a PR! - See :class:`SmartBulb` for more examples. + See :class:`IotBulb` for more examples. """ LIGHT_SERVICE = "smartlife.iot.lightStrip" diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 288d53763..50db5052c 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -18,12 +18,12 @@ class IotPlug(IotDevice): r"""Representation of a TP-Link Smart Plug. - To initialize, you have to await :func:`update()` at least once. + To initialize, you have to await :meth:`update()` at least once. This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, which will not change the cached values, - but you must await :func:`update()` separately. + but you must await :meth:`update()` separately. Errors reported by the device are raised as :class:`KasaException`\s, and should be handled by the user of the library. diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index a4b2ab996..500c8c706 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -7,6 +7,9 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from datetime import tzinfo + from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus @@ -21,7 +24,17 @@ ) from .iotmodule import IotModule from .iotplug import IotPlug -from .modules import Antitheft, Cloud, Countdown, Emeter, Led, Schedule, Time, Usage +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + HomeKit, + Led, + Schedule, + Time, + Usage, +) _LOGGER = logging.getLogger(__name__) @@ -40,14 +53,14 @@ class IotStrip(IotDevice): A strip consists of the parent device and its children. All methods of the parent act on all children, while the child devices - share the common API with the :class:`SmartPlug` class. + share the common API with the :class:`IotPlug` class. - To initialize, you have to await :func:`update()` at least once. + To initialize, you have to await :meth:`update()` at least once. This will allow accessing the properties using the exposed properties. All changes to the device are done using awaitable methods, which will not change the cached values, - but you must await :func:`update()` separately. + but you must await :meth:`update()` separately. Errors reported by the device are raised as :class:`KasaException`\s, and should be handled by the user of the library. @@ -62,7 +75,7 @@ class IotStrip(IotDevice): All methods act on the whole strip: >>> for plug in strip.children: - >>> print(f"{plug.alias}: {plug.is_on}") + ... print(f"{plug.alias}: {plug.is_on}") Plug 1: True Plug 2: False Plug 3: False @@ -76,7 +89,7 @@ class IotStrip(IotDevice): >>> len(strip.children) 3 >>> for plug in strip.children: - >>> print(f"{plug.alias}: {plug.is_on}") + ... print(f"{plug.alias}: {plug.is_on}") Plug 1: False Plug 2: False Plug 3: False @@ -109,6 +122,7 @@ async def _initialize_modules(self) -> None: self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.Led, Led(self, "system")) self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.IotHomeKit, HomeKit(self, "smartlife.iot.homekit")) if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" @@ -161,11 +175,17 @@ async def _initialize_features(self) -> None: async def turn_on(self, **kwargs) -> dict: """Turn the strip on.""" - return await self._query_helper("system", "set_relay_state", {"state": 1}) + for plug in self.children: + if plug.is_off: + await plug.turn_on() + return {} async def turn_off(self, **kwargs) -> dict: """Turn the strip off.""" - return await self._query_helper("system", "set_relay_state", {"state": 0}) + for plug in self.children: + if plug.is_on: + await plug.turn_off() + return {} @property # type: ignore @requires_update @@ -332,6 +352,8 @@ async def _initialize_modules(self) -> None: self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + # Note: do not add a Time module to the child; time is device-level. + # Child exposes time/timezone by delegating to the parent. async def _initialize_features(self) -> None: """Initialize common features.""" @@ -424,6 +446,18 @@ def led(self) -> bool: """ return False + @property # type: ignore + @requires_update + def time(self) -> datetime: + """Return current time, delegated from the parent strip.""" + return self._parent.time + + @property # type: ignore + @requires_update + def timezone(self) -> tzinfo: + """Return timezone, delegated from the parent strip.""" + return self._parent.timezone + @property # type: ignore @requires_update def device_id(self) -> str: diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py index 65538341b..461540891 100644 --- a/kasa/iot/iottimezone.py +++ b/kasa/iot/iottimezone.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta, tzinfo +from datetime import UTC, datetime, timedelta, timezone, tzinfo from typing import cast -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..cachedzoneinfo import CachedZoneInfo @@ -14,7 +14,7 @@ async def get_timezone(index: int) -> tzinfo: """Get the timezone from the index.""" - if index > 109: + if index < 0 or index > 109: _LOGGER.error( "Unexpected index %s not configured as a timezone, defaulting to UTC", index ) @@ -25,7 +25,12 @@ async def get_timezone(index: int) -> tzinfo: async def get_timezone_index(tzone: tzinfo) -> int: - """Return the iot firmware index for a valid IANA timezone key.""" + """Return the iot firmware index for a valid IANA timezone key. + + If tzinfo is a ZoneInfo and its key is in TIMEZONE_INDEX, return that index. + Otherwise, compare annual offset behavior to find the best match. + Indices that cannot be loaded on this host are skipped. + """ if isinstance(tzone, ZoneInfo): name = tzone.key rev = {val: key for key, val in TIMEZONE_INDEX.items()} @@ -33,14 +38,23 @@ async def get_timezone_index(tzone: tzinfo) -> int: return rev[name] for i in range(110): - if _is_same_timezone(tzone, await get_timezone(i)): + try: + cand = await get_timezone(i) + except ZoneInfoNotFoundError: + continue + if _is_same_timezone(tzone, cand): return i - raise ValueError("Device does not support timezone %s", name) + raise ValueError( + f"Device does not support timezone {getattr(tzone, 'key', tzone)!r}" + ) async def get_matching_timezones(tzone: tzinfo) -> list[str]: - """Return the iot firmware index for a valid IANA timezone key.""" - matches = [] + """Return available IANA keys from TIMEZONE_INDEX that match the given tzinfo. + + Skips zones that cannot be resolved on the host. + """ + matches: list[str] = [] if isinstance(tzone, ZoneInfo): name = tzone.key vals = {val for val in TIMEZONE_INDEX.values()} @@ -48,7 +62,10 @@ async def get_matching_timezones(tzone: tzinfo) -> list[str]: matches.append(name) for i in range(110): - fw_tz = await get_timezone(i) + try: + fw_tz = await get_timezone(i) + except ZoneInfoNotFoundError: + continue if _is_same_timezone(tzone, fw_tz): match_key = cast(ZoneInfo, fw_tz).key if match_key not in matches: @@ -57,11 +74,7 @@ async def get_matching_timezones(tzone: tzinfo) -> list[str]: def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool: - """Return true if the timezones have the same utcffset and dst offset. - - Iot devices only support a limited static list of IANA timezones; this is used to - check if a static timezone matches the same utc offset and dst settings. - """ + """Return true if the timezones have the same UTC offset each day of the year.""" now = datetime.now() start_day = datetime(now.year, 1, 1, 12) for i in range(365): @@ -71,6 +84,83 @@ def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool: return True +def _dst_expected_from_key(key: str) -> bool | None: + """Infer if a zone key implies DST behavior (heuristic, no manual map). + + - Posix-style keys with two abbreviations like 'CST6CDT', 'MST7MDT' -> True + - Fixed abbreviation keys like 'EST', 'MST', 'HST' -> False + - 'Etc/*' zones are fixed-offset -> False + - Otherwise unknown -> None + """ + k = key.upper() + if k.startswith("ETC/"): + return False + # Two abbreviations with a number in between (e.g., CST6CDT) + if any(ch.isdigit() for ch in k) and any( + x in k for x in ("CDT", "PDT", "MDT", "EDT") + ): + return True + if k in {"UTC", "UCT", "GMT", "EST", "MST", "HST", "PST"}: + return False + return None + + +def _expected_dst_behavior_for_index(index: int) -> bool | None: + """Return whether the given index implies a DST-observing zone.""" + key = TIMEZONE_INDEX[index] + return _dst_expected_from_key(key) + + +async def _guess_timezone_by_offset( + offset: timedelta, when_utc: datetime, dst_expected: bool | None = None +) -> tzinfo: + """Pick a ZoneInfo from TIMEZONE_INDEX that exists on this host and matches. + + - offset: device's UTC offset at 'when_utc' + - when_utc: reference instant; naive is treated as UTC + - dst_expected: if True/False, prefer candidates that do/do not observe DST annually + + Returns the lowest-index matching ZoneInfo for determinism. + If none match, returns a fixed-offset timezone as a last resort. + """ + if when_utc.tzinfo is None: + when_utc = when_utc.replace(tzinfo=UTC) + else: + when_utc = when_utc.astimezone(UTC) + + year = when_utc.year + # Reference mid-winter and mid-summer dates to detect DST-observing candidates + jan_ref = datetime(year, 1, 15, 12, tzinfo=UTC) + jul_ref = datetime(year, 7, 15, 12, tzinfo=UTC) + + candidates: list[tuple[int, tzinfo, bool]] = [] + for idx, name in TIMEZONE_INDEX.items(): + try: + tz = await CachedZoneInfo.get_cached_zone_info(name) + except ZoneInfoNotFoundError: + continue + + cand_offset_now = when_utc.astimezone(tz).utcoffset() + if cand_offset_now != offset: + continue + + # Determine if this candidate observes DST (offset differs between Jan and Jul) + jan_off = jan_ref.astimezone(tz).utcoffset() + jul_off = jul_ref.astimezone(tz).utcoffset() + cand_observes_dst = jan_off != jul_off + + if dst_expected is None or cand_observes_dst == dst_expected: + candidates.append((idx, tz, cand_observes_dst)) + + if candidates: + candidates.sort(key=lambda it: it[0]) + chosen = candidates[0][1] + return chosen + + # No ZoneInfo matched; return fixed offset as a last resort + return timezone(offset) + + TIMEZONE_INDEX = { 0: "Etc/GMT+12", 1: "Pacific/Samoa", diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 6fd63a706..207839e4b 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,9 @@ from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown +from .dimmer import Dimmer from .emeter import Emeter +from .homekit import HomeKit from .led import Led from .light import Light from .lighteffect import LightEffect @@ -20,6 +22,7 @@ "Antitheft", "Cloud", "Countdown", + "Dimmer", "Emeter", "Led", "Light", @@ -32,4 +35,5 @@ "Schedule", "Time", "Usage", + "HomeKit", ] diff --git a/kasa/iot/modules/dimmer.py b/kasa/iot/modules/dimmer.py new file mode 100644 index 000000000..42a93ce56 --- /dev/null +++ b/kasa/iot/modules/dimmer.py @@ -0,0 +1,270 @@ +"""Implementation of the dimmer config module found in dimmers.""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any, Final, cast + +from ...exceptions import KasaException +from ...feature import Feature +from ..iotmodule import IotModule, merge + +_LOGGER = logging.getLogger(__name__) + + +def _td_to_ms(td: timedelta) -> int: + """ + Convert timedelta to integer milliseconds. + + Uses default float to integer rounding. + """ + return int(td / timedelta(milliseconds=1)) + + +class Dimmer(IotModule): + """Implements the dimmer config module.""" + + THRESHOLD_ABS_MIN: Final[int] = 0 + # Strange value, but verified against hardware (KS220). + THRESHOLD_ABS_MAX: Final[int] = 51 + FADE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0) + # Arbitrary, but set low intending GENTLE FADE for longer fades. + FADE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=10) + GENTLE_TIME_ABS_MIN: Final[timedelta] = timedelta(seconds=0) + # Arbitrary, but reasonable default. + GENTLE_TIME_ABS_MAX: Final[timedelta] = timedelta(seconds=120) + # Verified against KS220. + RAMP_RATE_ABS_MIN: Final[int] = 10 + # Verified against KS220. + RAMP_RATE_ABS_MAX: Final[int] = 50 + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_threshold_min", + name="Minimum dimming level", + icon="mdi:lightbulb-on-20", + attribute_getter="threshold_min", + attribute_setter="set_threshold_min", + range_getter=lambda: (self.THRESHOLD_ABS_MIN, self.THRESHOLD_ABS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_fade_off_time", + name="Dimmer fade off time", + icon="mdi:clock-in", + attribute_getter="fade_off_time", + attribute_setter="set_fade_off_time", + range_getter=lambda: ( + _td_to_ms(self.FADE_TIME_ABS_MIN), + _td_to_ms(self.FADE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_fade_on_time", + name="Dimmer fade on time", + icon="mdi:clock-out", + attribute_getter="fade_on_time", + attribute_setter="set_fade_on_time", + range_getter=lambda: ( + _td_to_ms(self.FADE_TIME_ABS_MIN), + _td_to_ms(self.FADE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_gentle_off_time", + name="Dimmer gentle off time", + icon="mdi:clock-in", + attribute_getter="gentle_off_time", + attribute_setter="set_gentle_off_time", + range_getter=lambda: ( + _td_to_ms(self.GENTLE_TIME_ABS_MIN), + _td_to_ms(self.GENTLE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_gentle_on_time", + name="Dimmer gentle on time", + icon="mdi:clock-out", + attribute_getter="gentle_on_time", + attribute_setter="set_gentle_on_time", + range_getter=lambda: ( + _td_to_ms(self.GENTLE_TIME_ABS_MIN), + _td_to_ms(self.GENTLE_TIME_ABS_MAX), + ), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="dimmer_ramp_rate", + name="Dimmer ramp rate", + icon="mdi:clock-fast", + attribute_getter="ramp_rate", + attribute_setter="set_ramp_rate", + range_getter=lambda: (self.RAMP_RATE_ABS_MIN, self.RAMP_RATE_ABS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + + def query(self) -> dict: + """Request Dimming configuration.""" + req = merge( + self.query_for_command("get_dimmer_parameters"), + self.query_for_command("get_default_behavior"), + ) + + return req + + @property + def config(self) -> dict[str, Any]: + """Return current configuration.""" + return self.data["get_dimmer_parameters"] + + @property + def threshold_min(self) -> int: + """Return the minimum dimming level for this dimmer.""" + return self.config["minThreshold"] + + async def set_threshold_min(self, min: int) -> dict: + """Set the minimum dimming level for this dimmer. + + The value will depend on the luminaries connected to the dimmer. + + :param min: The minimum dimming level, in the range 0-51. + """ + if min < self.THRESHOLD_ABS_MIN or min > self.THRESHOLD_ABS_MAX: + raise KasaException( + "Minimum dimming threshold is outside the supported range: " + f"{self.THRESHOLD_ABS_MIN}-{self.THRESHOLD_ABS_MAX}" + ) + return await self.call("calibrate_brightness", {"minThreshold": min}) + + @property + def fade_off_time(self) -> timedelta: + """Return the fade off animation duration.""" + return timedelta(milliseconds=cast(int, self.config["fadeOffTime"])) + + async def set_fade_off_time(self, time: int | timedelta) -> dict: + """Set the duration of the fade off animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX: + raise KasaException( + "Fade time is outside the bounds of the supported range:" + f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}" + ) + return await self.call("set_fade_off_time", {"fadeTime": _td_to_ms(time)}) + + @property + def fade_on_time(self) -> timedelta: + """Return the fade on animation duration.""" + return timedelta(milliseconds=cast(int, self.config["fadeOnTime"])) + + async def set_fade_on_time(self, time: int | timedelta) -> dict: + """Set the duration of the fade on animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.FADE_TIME_ABS_MIN or time > self.FADE_TIME_ABS_MAX: + raise KasaException( + "Fade time is outside the bounds of the supported range:" + f"{self.FADE_TIME_ABS_MIN}-{self.FADE_TIME_ABS_MAX}" + ) + return await self.call("set_fade_on_time", {"fadeTime": _td_to_ms(time)}) + + @property + def gentle_off_time(self) -> timedelta: + """Return the gentle fade off animation duration.""" + return timedelta(milliseconds=cast(int, self.config["gentleOffTime"])) + + async def set_gentle_off_time(self, time: int | timedelta) -> dict: + """Set the duration of the gentle fade off animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX: + raise KasaException( + "Gentle off time is outside the bounds of the supported range: " + f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}." + ) + return await self.call("set_gentle_off_time", {"duration": _td_to_ms(time)}) + + @property + def gentle_on_time(self) -> timedelta: + """Return the gentle fade on animation duration.""" + return timedelta(milliseconds=cast(int, self.config["gentleOnTime"])) + + async def set_gentle_on_time(self, time: int | timedelta) -> dict: + """Set the duration of the gentle fade on animation. + + :param time: The animation duration, in ms. + """ + if isinstance(time, int): + time = timedelta(milliseconds=time) + if time < self.GENTLE_TIME_ABS_MIN or time > self.GENTLE_TIME_ABS_MAX: + raise KasaException( + "Gentle off time is outside the bounds of the supported range: " + f"{self.GENTLE_TIME_ABS_MIN}-{self.GENTLE_TIME_ABS_MAX}." + ) + return await self.call("set_gentle_on_time", {"duration": _td_to_ms(time)}) + + @property + def ramp_rate(self) -> int: + """Return the rate that the dimmer buttons increment the dimmer level.""" + return self.config["rampRate"] + + async def set_ramp_rate(self, rate: int) -> dict: + """Set how quickly to ramp the dimming level when using the dimmer buttons. + + :param rate: The rate to increment the dimming level with each press. + """ + if rate < self.RAMP_RATE_ABS_MIN or rate > self.RAMP_RATE_ABS_MAX: + raise KasaException( + "Gentle off time is outside the bounds of the supported range:" + f"{self.RAMP_RATE_ABS_MIN}-{self.RAMP_RATE_ABS_MAX}" + ) + return await self.call("set_button_ramp_rate", {"rampRate": rate}) diff --git a/kasa/iot/modules/homekit.py b/kasa/iot/modules/homekit.py new file mode 100644 index 000000000..935f87f9f --- /dev/null +++ b/kasa/iot/modules/homekit.py @@ -0,0 +1,53 @@ +"""Implementation of HomeKit module for IOT devices that natively support HomeKit.""" + +from __future__ import annotations + +from typing import Any + +from ...feature import Feature +from ..iotmodule import IotModule + + +class HomeKit(IotModule): + """Implementation of HomeKit module for IOT devices.""" + + def query(self) -> dict: + """Request HomeKit setup info.""" + return {"smartlife.iot.homekit": {"setup_info_get": {}}} + + @property + def info(self) -> dict[str, Any]: + """Return the HomeKit setup info.""" + # Only return info if the module has data + if self._module not in self._device._last_update: + return {} + return self.data.get("setup_info_get", {}) + + @property + def setup_code(self) -> str: + """Return the HomeKit setup code.""" + return self.info["setup_code"] + + @property + def setup_payload(self) -> str: + """Return the HomeKit setup payload.""" + return self.info["setup_payload"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + # Only add features if the device supports the module + data = self._device._last_update.get(self._module, {}) + if not data or "setup_info_get" not in data: + return + + self._add_feature( + Feature( + self._device, + container=self, + id="homekit_setup_code", + name="HomeKit setup code", + attribute_getter="setup_code", + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 5fdbf014d..fa9535908 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -3,13 +3,14 @@ from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Annotated, cast from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature -from ...interfaces.light import HSV, ColorTempRange, LightState +from ...interfaces.light import HSV, LightState from ...interfaces.light import Light as LightInterface +from ...module import FeatureAttribute from ..iotmodule import IotModule if TYPE_CHECKING: @@ -32,7 +33,7 @@ def _initialize_features(self) -> None: super()._initialize_features() device = self._device - if self._device._is_dimmable: + if device._is_dimmable: self._add_feature( Feature( device, @@ -46,7 +47,9 @@ def _initialize_features(self) -> None: category=Feature.Category.Primary, ) ) - if self._device._is_variable_color_temp: + if device._is_variable_color_temp: + if TYPE_CHECKING: + assert isinstance(device, IotBulb) self._add_feature( Feature( device=device, @@ -55,12 +58,12 @@ def _initialize_features(self) -> None: container=self, attribute_getter="color_temp", attribute_setter="set_color_temp", - range_getter="valid_temperature_range", + range_getter=lambda: device._valid_temperature_range, category=Feature.Category.Primary, type=Feature.Type.Number, ) ) - if self._device._is_color: + if device._is_color: self._add_feature( Feature( device=device, @@ -90,18 +93,13 @@ def _get_bulb_device(self) -> IotBulb | None: return None @property # type: ignore - def is_dimmable(self) -> int: - """Whether the bulb supports brightness changes.""" - return self._device._is_dimmable - - @property # type: ignore - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" return self._device._brightness async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. A value of 0 will turn off the light. :param int brightness: brightness in percent @@ -112,28 +110,7 @@ async def set_brightness( ) @property - def is_color(self) -> bool: - """Whether the light supports color changes.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._is_color - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._is_variable_color_temp - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._has_effects - - @property - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -149,7 +126,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -164,19 +141,7 @@ async def set_hsv( return await bulb._set_hsv(hue, saturation, value, transition=transition) @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if ( - bulb := self._get_bulb_device() - ) is None or not bulb._is_variable_color_temp: - raise KasaException("Light does not support colortemp.") - return bulb._valid_temperature_range - - @property - def color_temp(self) -> int: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" if ( bulb := self._get_bulb_device() @@ -186,7 +151,7 @@ def color_temp(self) -> int: async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -242,17 +207,18 @@ def state(self) -> LightState: return self._light_state async def _post_update_hook(self) -> None: - if self._device.is_on is False: + device = self._device + if device.is_on is False: state = LightState(light_on=False) else: state = LightState(light_on=True) - if self.is_dimmable: + if device._is_dimmable: state.brightness = self.brightness - if self.is_color: + if device._is_color: hsv = self.hsv state.hue = hsv.hue state.saturation = hsv.saturation - if self.is_variable_color_temp: + if device._is_variable_color_temp: state.color_temp = self.color_temp self._light_state = state diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index cdfaaae16..3a41fb5f6 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -12,20 +12,11 @@ class LightEffect(IotModule, LightEffectInterface): @property def effect(self) -> str: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ + """Return effect name.""" eff = self.data["lighting_effect_state"] name = eff["name"] if eff["enable"]: - return name - + return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM return self.LIGHT_EFFECTS_OFF @property diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index d97bfc4a8..3330af69f 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -54,7 +54,7 @@ class LightPreset(IotModule, LightPresetInterface): async def _post_update_hook(self) -> None: """Update the internal presets.""" self._presets = { - f"Light preset {index+1}": IotLightPreset.from_dict(vals) + f"Light preset {index + 1}": IotLightPreset.from_dict(vals) for index, vals in enumerate(self.data["preferred_state"]) # Devices may list some light effects along with normal presets but these # are handled by the LightEffect module so exclude preferred states with id @@ -85,17 +85,19 @@ def preset_states_list(self) -> Sequence[IotLightPreset]: def preset(self) -> str: """Return current preset name.""" light = self._device.modules[Module.Light] + is_color = light.has_feature("hsv") + is_variable_color_temp = light.has_feature("color_temp") + brightness = light.brightness - color_temp = light.color_temp if light.is_variable_color_temp else None - h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + color_temp = light.color_temp if is_variable_color_temp else None + + h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None) for preset_name, preset in self._presets.items(): if ( preset.brightness == brightness - and ( - preset.color_temp == color_temp or not light.is_variable_color_temp - ) - and (preset.hue == h or not light.is_color) - and (preset.saturation == s or not light.is_color) + and (preset.color_temp == color_temp or not is_variable_color_temp) + and (preset.hue == h or not is_color) + and (preset.saturation == s or not is_color) ): return preset_name return self.PRESET_NOT_SET @@ -107,7 +109,7 @@ async def set_preset( """Set a light preset for the device.""" light = self._device.modules[Module.Light] if preset_name == self.PRESET_NOT_SET: - if light.is_color: + if light.has_feature("hsv"): preset = LightState(hue=0, saturation=0, brightness=100) else: preset = LightState(brightness=100) diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index e65cbd93b..a795b449a 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging +import math +from dataclasses import dataclass from enum import Enum from ...exceptions import KasaException from ...feature import Feature -from ..iotmodule import IotModule +from ..iotmodule import IotModule, merge _LOGGER = logging.getLogger(__name__) @@ -20,6 +22,71 @@ class Range(Enum): Near = 2 Custom = 3 + def __str__(self) -> str: + return self.name + + +@dataclass +class PIRConfig: + """Dataclass representing a PIR sensor configuration.""" + + enabled: bool + adc_min: int + adc_max: int + range: Range + threshold: int + + @property + def adc_mid(self) -> int: + """Compute the ADC midpoint from the configured ADC Max and Min values.""" + return math.floor(abs(self.adc_max - self.adc_min) / 2) + + +@dataclass +class PIRStatus: + """Dataclass representing the current trigger state of an ADC PIR sensor.""" + + pir_config: PIRConfig + adc_value: int + + @property + def pir_value(self) -> int: + """ + Get the PIR status value in integer form. + + Computes the PIR status value that this object represents, + using the given PIR configuration. + """ + return self.pir_config.adc_mid - self.adc_value + + @property + def pir_percent(self) -> float: + """ + Get the PIR status value in percentile form. + + Computes the PIR status percentage that this object represents, + using the given PIR configuration. + """ + value = self.pir_value + divisor = ( + (self.pir_config.adc_mid - self.pir_config.adc_min) + if (value < 0) + else (self.pir_config.adc_max - self.pir_config.adc_mid) + ) + return (float(value) / divisor) * 100 + + @property + def pir_triggered(self) -> bool: + """ + Get the PIR status trigger state. + + Compute the PIR trigger state this object represents, + using the given PIR configuration. + """ + return (self.pir_config.enabled) and ( + abs(self.pir_percent) > (100 - self.pir_config.threshold) + ) + class Motion(IotModule): """Implements the motion detection (PIR) module.""" @@ -30,6 +97,11 @@ def _initialize_features(self) -> None: if "get_config" not in self.data: return + # Require that ADC value is also present. + if "get_adc_value" not in self.data: + _LOGGER.warning("%r initialized, but no get_adc_value in response") + return + if "enable" not in self.config: _LOGGER.warning("%r initialized, but no enable in response") return @@ -48,9 +120,143 @@ def _initialize_features(self) -> None: ) ) + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_range", + name="Motion Sensor Range", + icon="mdi:motion-sensor", + attribute_getter="range", + attribute_setter="_set_range_from_str", + type=Feature.Type.Choice, + choices_getter="ranges", + category=Feature.Category.Config, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_threshold", + name="Motion Sensor Threshold", + icon="mdi:motion-sensor", + attribute_getter="threshold", + attribute_setter="set_threshold", + type=Feature.Type.Number, + category=Feature.Category.Config, + range_getter=lambda: (0, 100), + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_triggered", + name="PIR Triggered", + icon="mdi:motion-sensor", + attribute_getter="pir_triggered", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Primary, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_value", + name="PIR Value", + icon="mdi:motion-sensor", + attribute_getter="pir_value", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Info, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_value", + name="PIR ADC Value", + icon="mdi:motion-sensor", + attribute_getter="adc_value", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_min", + name="PIR ADC Min", + icon="mdi:motion-sensor", + attribute_getter="adc_min", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_mid", + name="PIR ADC Mid", + icon="mdi:motion-sensor", + attribute_getter="adc_mid", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_adc_max", + name="PIR ADC Max", + icon="mdi:motion-sensor", + attribute_getter="adc_max", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_percent", + name="PIR Percentile", + icon="mdi:motion-sensor", + attribute_getter="pir_percent", + attribute_setter=None, + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + unit_getter=lambda: "%", + ) + ) + def query(self) -> dict: """Request PIR configuration.""" - return self.query_for_command("get_config") + req = merge( + self.query_for_command("get_config"), + self.query_for_command("get_adc_value"), + ) + + return req @property def config(self) -> dict: @@ -58,34 +264,103 @@ def config(self) -> dict: return self.data["get_config"] @property - def range(self) -> Range: - """Return motion detection range.""" - return Range(self.config["trigger_index"]) + def pir_config(self) -> PIRConfig: + """Return PIR sensor configuration.""" + pir_range = Range(self.config["trigger_index"]) + return PIRConfig( + enabled=bool(self.config["enable"]), + adc_min=int(self.config["min_adc"]), + adc_max=int(self.config["max_adc"]), + range=pir_range, + threshold=self.get_range_threshold(pir_range), + ) @property def enabled(self) -> bool: """Return True if module is enabled.""" - return bool(self.config["enable"]) + return self.pir_config.enabled + + @property + def adc_min(self) -> int: + """Return minimum ADC sensor value.""" + return self.pir_config.adc_min + + @property + def adc_max(self) -> int: + """Return maximum ADC sensor value.""" + return self.pir_config.adc_max + + @property + def adc_mid(self) -> int: + """ + Return the midpoint for the ADC. + + The midpoint represents the zero point for the PIR sensor waveform. + + Currently this is estimated by: + math.floor(abs(adc_max - adc_min) / 2) + """ + return self.pir_config.adc_mid async def set_enabled(self, state: bool) -> dict: """Enable/disable PIR.""" return await self.call("set_enable", {"enable": int(state)}) - async def set_range( - self, *, range: Range | None = None, custom_range: int | None = None - ) -> dict: - """Set the range for the sensor. + @property + def ranges(self) -> list[str]: + """Return set of supported range classes.""" + range_min = 0 + range_max = len(self.config["array"]) + valid_ranges = list() + for r in Range: + if (r.value >= range_min) and (r.value < range_max): + valid_ranges.append(r.name) + return valid_ranges + + @property + def range(self) -> Range: + """Return motion detection Range.""" + return self.pir_config.range - :param range: for using standard ranges - :param custom_range: range in decimeters, overrides the range parameter + async def set_range(self, range: Range) -> dict: + """Set the Range for the sensor. + + :param Range: the range class to use. """ - if custom_range is not None: - payload = {"index": Range.Custom.value, "value": custom_range} - elif range is not None: - payload = {"index": range.value} - else: - raise KasaException("Either range or custom_range need to be defined") + payload = {"index": range.value} + return await self.call("set_trigger_sens", payload) + def _parse_range_value(self, value: str) -> Range: + """Attempt to parse a range value from the given string.""" + value = value.strip().capitalize() + try: + return Range[value] + except KeyError: + raise KasaException( + f"Invalid range value: '{value}'." + f" Valid options are: {Range._member_names_}" + ) from KeyError + + async def _set_range_from_str(self, input: str) -> dict: + value = self._parse_range_value(input) + return await self.set_range(range=value) + + def get_range_threshold(self, range_type: Range) -> int: + """Get the distance threshold at which the PIR sensor is will trigger.""" + if range_type.value < 0 or range_type.value >= len(self.config["array"]): + raise KasaException( + "Range type is outside the bounds of the configured device ranges." + ) + return int(self.config["array"][range_type.value]) + + @property + def threshold(self) -> int: + """Return motion detection Range.""" + return self.pir_config.threshold + + async def set_threshold(self, value: int) -> dict: + """Set the distance threshold at which the PIR sensor is will trigger.""" + payload = {"index": Range.Custom.value, "value": value} return await self.call("set_trigger_sens", payload) @property @@ -100,3 +375,34 @@ async def set_inactivity_timeout(self, timeout: int) -> dict: to avoid reverting this back to 60 seconds after a period of time. """ return await self.call("set_cold_time", {"cold_time": timeout}) + + @property + def pir_state(self) -> PIRStatus: + """Return cached PIR status.""" + return PIRStatus(self.pir_config, self.data["get_adc_value"]["value"]) + + async def get_pir_state(self) -> PIRStatus: + """Return real-time PIR status.""" + latest = await self.call("get_adc_value") + self.data["get_adc_value"] = latest + return PIRStatus(self.pir_config, latest["value"]) + + @property + def adc_value(self) -> int: + """Return motion adc value.""" + return self.pir_state.adc_value + + @property + def pir_value(self) -> int: + """Return the computed PIR sensor value.""" + return self.pir_state.pir_value + + @property + def pir_percent(self) -> float: + """Return the computed PIR sensor value, in percentile form.""" + return self.pir_state.pir_percent + + @property + def pir_triggered(self) -> bool: + """Return if the motion sensor has been triggered.""" + return self.pir_state.pir_triggered diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 896172de6..a9f3704b6 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,12 +2,19 @@ from __future__ import annotations -from datetime import UTC, datetime, tzinfo +import contextlib +from datetime import UTC, datetime, timedelta, tzinfo +from zoneinfo import ZoneInfoNotFoundError from ...exceptions import KasaException from ...interfaces import Time as TimeInterface from ..iotmodule import IotModule, merge -from ..iottimezone import get_timezone, get_timezone_index +from ..iottimezone import ( + _expected_dst_behavior_for_index, + _guess_timezone_by_offset, + get_timezone, + get_timezone_index, +) class Time(IotModule, TimeInterface): @@ -23,9 +30,46 @@ def query(self) -> dict: return q async def _post_update_hook(self) -> None: - """Perform actions after a device update.""" + """Perform actions after a device update. + + If the configured zone is not available on this host, compute the device's + current UTC offset and choose a best-match available zone, preferring DST- + observing candidates when the original index implies DST. As a last resort, + use a fixed-offset timezone. + """ if res := self.data.get("get_timezone"): - self._timezone = await get_timezone(res.get("index")) + idx = res.get("index") + try: + self._timezone = await get_timezone(idx) + return + except ZoneInfoNotFoundError: + pass # fall through to offset-based match + + gt = self.data.get("get_time") + if gt: + device_local = datetime( + gt["year"], + gt["month"], + gt["mday"], + gt["hour"], + gt["min"], + gt["sec"], + ) + now_utc = datetime.now(UTC) + delta = device_local - now_utc.replace(tzinfo=None) + rounded = timedelta(seconds=60 * round(delta.total_seconds() / 60)) + + dst_expected = None + if res := self.data.get("get_timezone"): + idx = res.get("index") + with contextlib.suppress(KeyError): + dst_expected = _expected_dst_behavior_for_index(idx) + + self._timezone = await _guess_timezone_by_offset( + rounded, when_utc=now_utc, dst_expected=dst_expected + ) + else: + self._timezone = UTC @property def time(self) -> datetime: diff --git a/kasa/json.py b/kasa/json.py index 21c6fa00e..8a0eab7b4 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -8,18 +8,24 @@ try: import orjson - def dumps(obj: Any, *, default: Callable | None = None) -> str: + def dumps( + obj: Any, *, default: Callable | None = None, indent: bool = False + ) -> str: """Dump JSON.""" - return orjson.dumps(obj).decode() + return orjson.dumps( + obj, option=orjson.OPT_INDENT_2 if indent else None + ).decode() loads = orjson.loads except ImportError: import json - def dumps(obj: Any, *, default: Callable | None = None) -> str: + def dumps( + obj: Any, *, default: Callable | None = None, indent: bool = False + ) -> str: """Dump JSON.""" # Separators specified for consistency with orjson - return json.dumps(obj, separators=(",", ":")) + return json.dumps(obj, separators=(",", ":"), indent=2 if indent else None) loads = json.loads diff --git a/kasa/module.py b/kasa/module.py index 2b2e65f93..5d717891a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -6,10 +6,10 @@ >>> from kasa import Discover, Module >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.3", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.3", +... username="user@example.com", +... password="great_password" +... ) >>> await dev.update() >>> print(dev.alias) Living Room Bulb @@ -18,14 +18,17 @@ check for the existence of the module: >>> if light := dev.modules.get("Light"): ->>> print(light.brightness) +... print(light.brightness) 100 +.. include:: ../featureattributes.md + :parser: myst_parser.sphinx_ + To see whether a device supports specific functionality, you can check whether the module has that feature: >>> if light.has_feature("hsv"): ->>> print(light.hsv) +... print(light.hsv) HSV(hue=0, saturation=100, value=100) If you know or expect the module to exist you can access by index: @@ -41,8 +44,8 @@ >>> light_effect = dev.modules.get("LightEffect") >>> light_effect_typed = dev.modules.get(Module.LightEffect) >>> if TYPE_CHECKING: ->>> reveal_type(light_effect) # Static checker will reveal: str ->>> reveal_type(light_effect_typed) # Static checker will reveal: LightEffect +... reveal_type(light_effect) # Static checker will reveal: str +... reveal_type(light_effect_typed) # Static checker will reveal: LightEffect """ @@ -78,6 +81,9 @@ class FeatureAttribute: """Class for annotating attributes bound to feature.""" + def __init__(self, feature_name: str | None = None) -> None: + self.feature_name = feature_name + def __repr__(self) -> str: return "FeatureAttribute" @@ -90,6 +96,8 @@ class Module(ABC): """ # Common Modules + Alarm: Final[ModuleName[interfaces.Alarm]] = ModuleName("Alarm") + ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup") Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") @@ -103,13 +111,14 @@ class Module(ABC): IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") + IotDimmer: Final[ModuleName[iot.Dimmer]] = ModuleName("dimmer") IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotHomeKit: Final[ModuleName[iot.HomeKit]] = ModuleName("homekit") # SMART only Modules - Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") @@ -149,16 +158,38 @@ class Module(ABC): ChildProtection: Final[ModuleName[smart.ChildProtection]] = ModuleName( "ChildProtection" ) + ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") + PowerProtection: Final[ModuleName[smart.PowerProtection]] = ModuleName( + "PowerProtection" + ) + + HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") + Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") # SMARTCAM only modules Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera") + LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask") + PanTilt: Final[ModuleName[smartcam.PanTilt]] = ModuleName("PanTilt") + + # Vacuum modules + Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") + Consumables: Final[ModuleName[smart.Consumables]] = ModuleName("Consumables") + Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") + Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") + Mop: Final[ModuleName[smart.Mop]] = ModuleName("Mop") + CleanRecords: Final[ModuleName[smart.CleanRecords]] = ModuleName("CleanRecords") def __init__(self, device: Device, module: str) -> None: self._device = device self._module = module self._module_features: dict[str, Feature] = {} + @property + def device(self) -> Device: + """Return the device exposing the module.""" + return self._device + @property def _all_features(self) -> dict[str, Feature]: """Get the features for this module and any sub modules.""" @@ -217,7 +248,7 @@ def __repr__(self) -> str: ) -def _is_bound_feature(attribute: property | Callable) -> bool: +def _get_feature_attribute(attribute: property | Callable) -> FeatureAttribute | None: """Check if an attribute is bound to a feature with FeatureAttribute.""" if isinstance(attribute, property): hints = get_type_hints(attribute.fget, include_extras=True) @@ -228,9 +259,9 @@ def _is_bound_feature(attribute: property | Callable) -> bool: metadata = hints["return"].__metadata__ for meta in metadata: if isinstance(meta, FeatureAttribute): - return True + return meta - return False + return None @cache @@ -257,12 +288,17 @@ def _get_bound_feature( f"module {module.__class__.__name__}" ) - if not _is_bound_feature(attribute_callable): + if not (fa := _get_feature_attribute(attribute_callable)): raise KasaException( f"Attribute {attribute_name} of module {module.__class__.__name__}" " is not bound to a feature" ) + # If a feature_name was passed to the FeatureAttribute use that to check + # for the feature. Otherwise check the getters and setters in the features + if fa.feature_name: + return module._all_features.get(fa.feature_name) + check = {attribute_name, attribute_callable} for feature in module._all_features.values(): if (getter := feature.attribute_getter) and getter in check: diff --git a/kasa/protocols/__init__.py b/kasa/protocols/__init__.py index 44130d7f2..b994d7324 100644 --- a/kasa/protocols/__init__.py +++ b/kasa/protocols/__init__.py @@ -2,6 +2,7 @@ from .iotprotocol import IotProtocol from .protocol import BaseProtocol +from .smartcamprotocol import SmartCamProtocol from .smartprotocol import SmartErrorCode, SmartProtocol __all__ = [ @@ -9,4 +10,5 @@ "IotProtocol", "SmartErrorCode", "SmartProtocol", + "SmartCamProtocol", ] diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py index 3bc6c4545..8d85733e2 100755 --- a/kasa/protocols/iotprotocol.py +++ b/kasa/protocols/iotprotocol.py @@ -4,6 +4,7 @@ import asyncio import logging +import re from collections.abc import Callable from pprint import pformat as pf from typing import TYPE_CHECKING, Any @@ -25,19 +26,37 @@ _LOGGER = logging.getLogger(__name__) + +def _mask_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]: + def mask_child(child: dict[str, Any], index: int) -> dict[str, Any]: + result = { + **child, + "id": f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}", + } + # Will leave empty aliases as blank + if child.get("alias"): + result["alias"] = f"#MASKED_NAME# {index + 1}" + return result + + return [mask_child(child, index) for index, child in enumerate(children)] + + REDACTORS: dict[str, Callable[[Any], Any] | None] = { "latitude": lambda x: 0, "longitude": lambda x: 0, "latitude_i": lambda x: 0, "longitude_i": lambda x: 0, "deviceId": lambda x: "REDACTED_" + x[9::], - "id": lambda x: "REDACTED_" + x[9::], + "children": _mask_children, "alias": lambda x: "#MASKED_NAME#" if x else "", "mac": mask_mac, "mic_mac": mask_mac, "ssid": lambda x: "#MASKED_SSID#" if x else "", "oemId": lambda x: "REDACTED_" + x[9::], "username": lambda _: "user@example.com", # cnCloud + "hwId": lambda x: "REDACTED_" + x[9::], + "setup_code": lambda x: re.sub(r"\w", "0", x), # homekit + "setup_payload": lambda x: re.sub(r"\w", "0", x), # homekit } @@ -82,12 +101,26 @@ async def _query(self, request: str, retry_count: int = 3) -> dict: ) raise auex except _RetryableError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a retryable error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a timeout error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) diff --git a/kasa/protocols/protocol.py b/kasa/protocols/protocol.py index 211a7b5ae..fb09b8828 100755 --- a/kasa/protocols/protocol.py +++ b/kasa/protocols/protocol.py @@ -66,6 +66,8 @@ def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> def mask_mac(mac: str) -> str: """Return mac address with last two octects blanked.""" + if len(mac) == 12: + return f"{mac[:6]}000000" delim = ":" if ":" in mac else "-" rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) return f"{mac[:8]}{delim}{rest}" diff --git a/kasa/protocols/smartcamprotocol.py b/kasa/protocols/smartcamprotocol.py index 12caa207b..9bf40f7d1 100644 --- a/kasa/protocols/smartcamprotocol.py +++ b/kasa/protocols/smartcamprotocol.py @@ -5,7 +5,7 @@ import logging from dataclasses import dataclass from pprint import pformat as pf -from typing import Any +from typing import Any, cast from ..exceptions import ( AuthenticationError, @@ -19,7 +19,7 @@ SMART_RETRYABLE_ERRORS, SmartErrorCode, ) -from . import SmartProtocol +from .smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -49,10 +49,13 @@ class SingleRequest: class SmartCamProtocol(SmartProtocol): """Class for SmartCam Protocol.""" - async def _handle_response_lists( - self, response_result: dict[str, Any], method: str, retry_count: int - ) -> None: - pass + def _get_list_request( + self, method: str, params: dict | None, start_index: int + ) -> dict: + # All smartcam requests have params + params = cast(dict, params) + module_name = next(iter(params)) + return {method: {module_name: {"start_index": start_index}}} def _handle_response_error_code( self, resp_dict: dict, method: str, raise_on_error: bool = True @@ -147,7 +150,9 @@ async def _execute_query( if len(request) == 1 and method in {"get", "set", "do", "multipleRequest"}: single_request = self._get_smart_camera_single_request(request) else: - return await self._execute_multiple_query(request, retry_count) + return await self._execute_multiple_query( + request, retry_count, iterate_list_pages + ) else: single_request = self._make_smart_camera_single_request(request) @@ -239,11 +244,15 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: responses = response["multipleRequest"]["responses"] response_dict = {} + + # Raise errors for single calls + raise_on_error = len(requests) == 1 + for index_id, response in enumerate(responses): response_data = response["result"]["response_data"] method = methods[index_id] self._handle_response_error_code( - response_data, method, raise_on_error=False + response_data, method, raise_on_error=raise_on_error ) response_dict[method] = response_data.get("result") diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 80e76ca6e..ad3e7331e 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -9,6 +9,7 @@ import asyncio import base64 import logging +import re import time import uuid from collections.abc import Callable @@ -35,6 +36,18 @@ _LOGGER = logging.getLogger(__name__) + +def _mask_area_list(area_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + def mask_area(area: dict[str, Any]) -> dict[str, Any]: + result = {**area} + # Will leave empty names as blank + if area.get("name"): + result["name"] = "I01BU0tFRF9OQU1FIw==" # #MASKED_NAME# + return result + + return [mask_area(area) for area in area_list] + + REDACTORS: dict[str, Callable[[Any], Any] | None] = { "latitude": lambda x: 0, "longitude": lambda x: 0, @@ -45,15 +58,43 @@ "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", "mac": mask_mac, - "ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", + "ssid": lambda x: "I01BU0tFRF9TU0lEIw==" if x else "", "bssid": lambda _: "000000000000", + "channel": lambda _: 0, "oem_id": lambda x: "REDACTED_" + x[9::], - "setup_code": None, # matter - "setup_payload": None, # matter - "mfi_setup_code": None, # mfi_ for homekit - "mfi_setup_id": None, - "mfi_token_token": None, - "mfi_token_uuid": None, + "hw_id": lambda x: "REDACTED_" + x[9::], + "fw_id": lambda x: "REDACTED_" + x[9::], + "setup_code": lambda x: re.sub(r"\w", "0", x), # matter + "setup_payload": lambda x: re.sub(r"\w", "0", x), # matter + "mfi_setup_code": lambda x: re.sub(r"\w", "0", x), # mfi_ for homekit + "mfi_setup_id": lambda x: re.sub(r"\w", "0", x), + "mfi_token_token": lambda x: re.sub(r"\w", "0", x), + "mfi_token_uuid": lambda x: re.sub(r"\w", "0", x), + "ip": lambda x: x, # don't redact but keep listed here for dump_devinfo + # smartcam + "dev_id": lambda x: "REDACTED_" + x[9::], + "ext_addr": lambda x: "REDACTED_" + x[9::], + "device_name": lambda x: "#MASKED_NAME#" if x else "", + "device_alias": lambda x: "#MASKED_NAME#" if x else "", + "alias": lambda x: "#MASKED_NAME#" if x else "", # child info on parent uses alias + "local_ip": lambda x: x, # don't redact but keep listed here for dump_devinfo + # robovac + "board_sn": lambda _: "000000000000", + "custom_sn": lambda _: "000000000000", + "location": lambda x: "#MASKED_NAME#" if x else "", + "map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "", + "map_name": lambda x: "I01BU0tFRF9OQU1FIw==", # #MASKED_NAME# + "area_list": _mask_area_list, + # unknown robovac binary blob in get_device_info + "cd": lambda x: "I01BU0tFRF9CSU5BUlkj", # #MASKED_BINARY# +} + +# Queries that are known not to work properly when sent as a +# multiRequest. They will not return the `method` key. +FORCE_SINGLE_REQUEST = { + "connectAp", + "getConnectStatus", + "scanApList", } @@ -76,6 +117,7 @@ def __init__( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) self._redact_data = True + self._method_missing_logged = False def get_smart_request(self, method: str, params: dict | None = None) -> str: """Get a request message as a string.""" @@ -157,22 +199,25 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: # make mypy happy, this should never be reached.. raise KasaException("Query reached somehow to unreachable") - async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dict: + async def _execute_multiple_query( + self, requests: dict, retry_count: int, iterate_list_pages: bool + ) -> dict: debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) multi_result: dict[str, Any] = {} smart_method = "multipleRequest" - multi_requests = [ - {"method": method, "params": params} if params else {"method": method} - for method, params in requests.items() - ] - - end = len(multi_requests) + end = len(requests) # The SmartCamProtocol sends requests with a length 1 as a # multipleRequest. The SmartProtocol doesn't so will never # raise_on_error raise_on_error = end == 1 + multi_requests = [ + {"method": method, "params": params} if params else {"method": method} + for method, params in requests.items() + if method not in FORCE_SINGLE_REQUEST + ] + # Break the requests down as there can be a size limit step = self._multi_request_batch_size if step == 1: @@ -192,7 +237,7 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) - batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}" + batch_name = f"multi-request-batch-{batch_num + 1}-of-{int(end / step) + 1}" if debug_enabled: _LOGGER.debug( "%s %s >> %s", @@ -233,22 +278,41 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic responses = response_step["result"]["responses"] for response in responses: - method = response["method"] + # some smartcam devices calls do not populate the method key + # these should be defined in DO_NOT_SEND_AS_MULTI_REQUEST. + if not (method := response.get("method")): + if not self._method_missing_logged: + # Avoid spamming the logs + self._method_missing_logged = True + _LOGGER.error( + "No method key in response for %s, skipping: %s", + self._host, + response_step, + ) + # These will end up being queried individually + continue + self._handle_response_error_code( response, method, raise_on_error=raise_on_error ) result = response.get("result", None) - await self._handle_response_lists( - result, method, retry_count=retry_count - ) + request_params = rp if (rp := requests.get(method)) else None + if iterate_list_pages and result: + await self._handle_response_lists( + result, method, request_params, retry_count=retry_count + ) multi_result[method] = result - # Multi requests don't continue after errors so requery any missing + + # Multi requests don't continue after errors so requery any missing. + # Will also query individually any DO_NOT_SEND_AS_MULTI_REQUEST. for method, params in requests.items(): if method not in multi_result: resp = await self._transport.send( self.get_smart_request(method, params) ) - self._handle_response_error_code(resp, method, raise_on_error=False) + self._handle_response_error_code( + resp, method, raise_on_error=raise_on_error + ) multi_result[method] = resp.get("result") return multi_result @@ -262,7 +326,9 @@ async def _execute_query( smart_method = next(iter(request)) smart_params = request[smart_method] else: - return await self._execute_multiple_query(request, retry_count) + return await self._execute_multiple_query( + request, retry_count, iterate_list_pages + ) else: smart_method = request smart_params = None @@ -289,12 +355,21 @@ async def _execute_query( result = response_data.get("result") if iterate_list_pages and result: await self._handle_response_lists( - result, smart_method, retry_count=retry_count + result, smart_method, smart_params, retry_count=retry_count ) return {smart_method: result} + def _get_list_request( + self, method: str, params: dict | None, start_index: int + ) -> dict: + return {method: {"start_index": start_index}} + async def _handle_response_lists( - self, response_result: dict[str, Any], method: str, retry_count: int + self, + response_result: dict[str, Any], + method: str, + params: dict | None, + retry_count: int, ) -> None: if ( response_result is None @@ -314,8 +389,9 @@ async def _handle_response_lists( ) ) while (list_length := len(response_result[response_list_name])) < list_sum: + request = self._get_list_request(method, params, list_length) response = await self._execute_query( - {method: {"start_index": list_length}}, + request, retry_count=retry_count, iterate_list_pages=False, ) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 99820cfaf..154042398 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -6,16 +6,23 @@ from .batterysensor import BatterySensor from .brightness import Brightness from .childdevice import ChildDevice +from .childlock import ChildLock from .childprotection import ChildProtection +from .childsetup import ChildSetup +from .clean import Clean +from .cleanrecords import CleanRecords from .cloud import Cloud from .color import Color from .colortemperature import ColorTemperature +from .consumables import Consumables from .contactsensor import ContactSensor from .devicemodule import DeviceModule +from .dustbin import Dustbin from .energy import Energy from .fan import Fan from .firmware import Firmware from .frostprotection import FrostProtection +from .homekit import HomeKit from .humiditysensor import HumiditySensor from .led import Led from .light import Light @@ -23,8 +30,13 @@ from .lightpreset import LightPreset from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition +from .matter import Matter +from .mop import Mop from .motionsensor import MotionSensor +from .overheatprotection import OverheatProtection +from .powerprotection import PowerProtection from .reportmode import ReportMode +from .speaker import Speaker from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor from .thermostat import Thermostat @@ -38,6 +50,8 @@ "Energy", "DeviceModule", "ChildDevice", + "ChildLock", + "ChildSetup", "BatterySensor", "HumiditySensor", "TemperatureSensor", @@ -63,5 +77,15 @@ "TriggerLogs", "FrostProtection", "Thermostat", + "Clean", + "Consumables", + "CleanRecords", "SmartLightEffect", + "PowerProtection", + "OverheatProtection", + "Speaker", + "HomeKit", + "Matter", + "Dustbin", + "Mop", ] diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index f1bf72363..cd6021829 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -2,13 +2,30 @@ from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, Annotated, Literal, TypeAlias from ...feature import Feature +from ...interfaces import Alarm as AlarmInterface +from ...module import FeatureAttribute from ..smartmodule import SmartModule +DURATION_MAX = 10 * 60 -class Alarm(SmartModule): +VOLUME_INT_TO_STR = { + 0: "mute", + 1: "low", + 2: "normal", + 3: "high", +} + +VOLUME_STR_LIST = [v for v in VOLUME_INT_TO_STR.values()] +VOLUME_INT_RANGE = (min(VOLUME_INT_TO_STR.keys()), max(VOLUME_INT_TO_STR.keys())) +VOLUME_STR_TO_INT = {v: k for k, v in VOLUME_INT_TO_STR.items()} + +AlarmVolume: TypeAlias = Literal["mute", "low", "normal", "high"] + + +class Alarm(SmartModule, AlarmInterface): """Implementation of alarm module.""" REQUIRED_COMPONENT = "alarm" @@ -21,10 +38,7 @@ def query(self) -> dict: } def _initialize_features(self) -> None: - """Initialize features. - - This is implemented as some features depend on device responses. - """ + """Initialize features.""" device = self._device self._add_feature( Feature( @@ -67,11 +81,37 @@ def _initialize_features(self) -> None: id="alarm_volume", name="Alarm volume", container=self, - attribute_getter="alarm_volume", + attribute_getter="_alarm_volume_str", attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices_getter=lambda: ["low", "normal", "high"], + choices_getter=lambda: VOLUME_STR_LIST, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_volume_level", + name="Alarm volume", + container=self, + attribute_getter="alarm_volume", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Number, + range_getter=lambda: VOLUME_INT_RANGE, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_duration", + name="Alarm duration", + container=self, + attribute_getter="alarm_duration", + attribute_setter="set_alarm_duration", + category=Feature.Category.Config, + type=Feature.Type.Number, + range_getter=lambda: (1, DURATION_MAX), ) ) self._add_feature( @@ -96,15 +136,16 @@ def _initialize_features(self) -> None: ) @property - def alarm_sound(self) -> str: + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: """Return current alarm sound.""" return self.data["get_alarm_configure"]["type"] - async def set_alarm_sound(self, sound: str) -> dict: + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: """Set alarm sound. See *alarm_sounds* for list of available sounds. """ + self._check_sound(sound) payload = self.data["get_alarm_configure"].copy() payload["type"] = sound return await self.call("set_alarm_configure", payload) @@ -115,16 +156,40 @@ def alarm_sounds(self) -> list[str]: return self.data["get_support_alarm_type_list"]["alarm_type_list"] @property - def alarm_volume(self) -> Literal["low", "normal", "high"]: + def alarm_volume(self) -> Annotated[int, FeatureAttribute("alarm_volume_level")]: + """Return alarm volume.""" + return VOLUME_STR_TO_INT[self._alarm_volume_str] + + @property + def _alarm_volume_str( + self, + ) -> Annotated[AlarmVolume, FeatureAttribute("alarm_volume")]: """Return alarm volume.""" return self.data["get_alarm_configure"]["volume"] - async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict: + async def set_alarm_volume( + self, volume: AlarmVolume | int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" + self._check_and_convert_volume(volume) payload = self.data["get_alarm_configure"].copy() payload["volume"] = volume return await self.call("set_alarm_configure", payload) + @property + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm duration.""" + return self.data["get_alarm_configure"]["duration"] + + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm duration.""" + self._check_duration(duration) + payload = self.data["get_alarm_configure"].copy() + payload["duration"] = duration + return await self.call("set_alarm_configure", payload) + @property def active(self) -> bool: """Return true if alarm is active.""" @@ -136,10 +201,62 @@ def source(self) -> str | None: src = self._device.sys_info["in_alarm_source"] return src if src else None - async def play(self) -> dict: - """Play alarm.""" - return await self.call("play_alarm") + async def play( + self, + *, + duration: int | None = None, + volume: int | AlarmVolume | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *volume* can be set to 'mute', 'low', 'normal', or 'high'. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + params: dict[str, str | int] = {} + + if duration is not None: + self._check_duration(duration) + params["alarm_duration"] = duration + + if volume is not None: + target_volume = self._check_and_convert_volume(volume) + params["alarm_volume"] = target_volume + + if sound is not None: + self._check_sound(sound) + params["alarm_type"] = sound + + return await self.call("play_alarm", params) async def stop(self) -> dict: """Stop alarm.""" return await self.call("stop_alarm") + + def _check_and_convert_volume(self, volume: str | int) -> str: + """Raise an exception on invalid volume.""" + if isinstance(volume, int): + volume = VOLUME_INT_TO_STR.get(volume, "invalid") + + if TYPE_CHECKING: + assert isinstance(volume, str) + + if volume not in VOLUME_INT_TO_STR.values(): + raise ValueError( + f"Invalid volume {volume} " + f"available: {VOLUME_INT_TO_STR.keys()}, {VOLUME_INT_TO_STR.values()}" + ) + + return volume + + def _check_duration(self, duration: int) -> None: + """Raise an exception on invalid duration.""" + if duration < 1 or duration > DURATION_MAX: + raise ValueError(f"Invalid duration {duration} available: 1-600") + + def _check_sound(self, sound: str) -> None: + """Raise an exception on invalid sound.""" + if sound not in self.alarm_sounds: + raise ValueError(f"Invalid sound {sound} available: {self.alarm_sounds}") diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 87072b104..aef100fc5 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -2,7 +2,11 @@ from __future__ import annotations +from typing import Annotated + +from ...exceptions import KasaException from ...feature import Feature +from ...module import FeatureAttribute from ..smartmodule import SmartModule @@ -14,18 +18,22 @@ class BatterySensor(SmartModule): def _initialize_features(self) -> None: """Initialize features.""" - self._add_feature( - Feature( - self._device, - "battery_low", - "Battery low", - container=self, - attribute_getter="battery_low", - icon="mdi:alert", - type=Feature.Type.BinarySensor, - category=Feature.Category.Debug, + if ( + "at_low_battery" in self._device.sys_info + or "is_low" in self._device.sys_info + ): + self._add_feature( + Feature( + self._device, + "battery_low", + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) ) - ) # Some devices, like T110 contact sensor do not report the battery percentage if "battery_percentage" in self._device.sys_info: @@ -48,11 +56,17 @@ def query(self) -> dict: return {} @property - def battery(self) -> int: + def battery(self) -> Annotated[int, FeatureAttribute()]: """Return battery level.""" return self._device.sys_info["battery_percentage"] @property - def battery_low(self) -> bool: + def battery_low(self) -> Annotated[bool, FeatureAttribute()]: """Return True if battery is low.""" - return self._device.sys_info["at_low_battery"] + is_low = self._device.sys_info.get( + "at_low_battery", self._device.sys_info.get("is_low") + ) + if is_low is None: + raise KasaException("Device does not report battery low status") + + return is_low diff --git a/kasa/smart/modules/childdevice.py b/kasa/smart/modules/childdevice.py index 4c3b99ded..086469ec8 100644 --- a/kasa/smart/modules/childdevice.py +++ b/kasa/smart/modules/childdevice.py @@ -3,10 +3,10 @@ >>> from kasa import Discover >>> >>> dev = await Discover.discover_single( ->>> "127.0.0.1", ->>> username="user@example.com", ->>> password="great_password" ->>> ) +... "127.0.0.1", +... username="user@example.com", +... password="great_password" +... ) >>> await dev.update() >>> print(dev.alias) Bedroom Power Strip @@ -14,7 +14,7 @@ All methods act on the whole strip: >>> for plug in dev.children: ->>> print(f"{plug.alias}: {plug.is_on}") +... print(f"{plug.alias}: {plug.is_on}") Plug 1: True Plug 2: False Plug 3: False @@ -28,7 +28,7 @@ >>> len(dev.children) 3 >>> for plug in dev.children: ->>> print(f"{plug.alias}: {plug.is_on}") +... print(f"{plug.alias}: {plug.is_on}") Plug 1: False Plug 2: False Plug 3: False @@ -38,6 +38,7 @@ True """ +from ...device_type import DeviceType from ..smartmodule import SmartModule @@ -46,3 +47,10 @@ class ChildDevice(SmartModule): REQUIRED_COMPONENT = "child_device" QUERY_GETTER_NAME = "get_child_device_list" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + if self._device.device_type is DeviceType.Hub: + q["get_child_device_component_list"] = None + return q diff --git a/kasa/smart/modules/childlock.py b/kasa/smart/modules/childlock.py new file mode 100644 index 000000000..1c5e72d9e --- /dev/null +++ b/kasa/smart/modules/childlock.py @@ -0,0 +1,37 @@ +"""Child lock module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class ChildLock(SmartModule): + """Implementation for child lock.""" + + REQUIRED_COMPONENT = "button_and_led" + QUERY_GETTER_NAME = "getChildLockInfo" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="child_lock", + name="Child lock", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + def enabled(self) -> bool: + """Return True if child lock is enabled.""" + return self.data["child_lock_status"] + + async def set_enabled(self, enabled: bool) -> dict: + """Set child lock.""" + return await self.call("setChildLockInfo", {"child_lock_status": enabled}) diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py new file mode 100644 index 000000000..f3bf88c8d --- /dev/null +++ b/kasa/smart/modules/childsetup.py @@ -0,0 +1,112 @@ +"""Implementation for child device setup. + +This module allows pairing and disconnecting child devices. +""" + +from __future__ import annotations + +import asyncio +import logging + +from ...feature import Feature +from ...interfaces.childsetup import ChildSetup as ChildSetupInterface +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class ChildSetup(SmartModule, ChildSetupInterface): + """Implementation for child device setup.""" + + REQUIRED_COMPONENT = "child_quick_setup" + QUERY_GETTER_NAME = "get_support_child_device_category" + _categories: list[str] = [] + + # Supported child device categories will hardly ever change + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="pair", + name="Pair", + container=self, + attribute_setter="pair", + category=Feature.Category.Config, + type=Feature.Type.Action, + ) + ) + + async def _post_update_hook(self) -> None: + self._categories = [ + cat["category"] for cat in self.data["device_category_list"] + ] + + @property + def supported_categories(self) -> list[str]: + """Supported child device categories.""" + return self._categories + + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair them.""" + await self.call("begin_scanning_child_device") + + _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) + await asyncio.sleep(timeout) + detected = await self._get_detected_devices() + + if not detected["child_device_list"]: + _LOGGER.warning( + "No devices found, make sure to activate pairing " + "mode on the devices to be added." + ) + return [] + + _LOGGER.info( + "Discovery done, found %s devices: %s", + len(detected["child_device_list"]), + detected, + ) + + return await self._add_devices(detected) + + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" + _LOGGER.info("Going to unpair %s from %s", device_id, self) + + payload = {"child_device_list": [{"device_id": device_id}]} + res = await self.call("remove_child_device_list", payload) + await self._device.update() + return res + + async def _add_devices(self, devices: dict) -> list[dict]: + """Add devices based on get_detected_device response. + + Pass the output from :ref:_get_detected_devices: as a parameter. + """ + await self.call("add_child_device_list", devices) + + await self._device.update() + + successes = [] + for detected in devices["child_device_list"]: + device_id = detected["device_id"] + + result = "not added" + if device_id in self._device._children: + result = "added" + successes.append(detected) + + msg = f"{detected['device_model']} - {device_id} - {result}" + _LOGGER.info("Added child to %s: %s", self._device.host, msg) + + return successes + + async def _get_detected_devices(self) -> dict: + """Return list of devices detected during scanning.""" + param = {"scan_list": self.data["device_category_list"]} + res = await self.call("get_scan_child_device_list", param) + _LOGGER.debug("Scan status: %s", res) + return res["get_scan_child_device_list"] diff --git a/kasa/smart/modules/clean.py b/kasa/smart/modules/clean.py new file mode 100644 index 000000000..5a62bad62 --- /dev/null +++ b/kasa/smart/modules/clean.py @@ -0,0 +1,579 @@ +"""Implementation of vacuum clean module.""" + +from __future__ import annotations + +import base64 +import logging +from dataclasses import dataclass +from datetime import timedelta +from enum import IntEnum, StrEnum +from typing import Annotated, Literal + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + +# Only known value for start_type in setSwitchClean; required for +# targeted cleaning modes (Room) but not for StandardHome. +_START_TYPE_DEFAULT = 1 + +# Only known value for the type parameter in getMapData. +_MAP_DATA_TYPE_DEFAULT = 0 + + +class Status(IntEnum): + """Status of vacuum.""" + + Idle = 0 + Cleaning = 1 + Mapping = 2 + GoingHome = 4 + Charging = 5 + Charged = 6 + Paused = 7 + Undocked = 8 + Error = 100 + + UnknownInternal = -1000 + + +class ErrorCode(IntEnum): + """Error codes for vacuum.""" + + Ok = 0 + SideBrushStuck = 2 + MainBrushStuck = 3 + WheelBlocked = 4 + Trapped = 6 + TrappedCliff = 7 + DustBinRemoved = 14 + UnableToMove = 15 + LidarBlocked = 16 + UnableToFindDock = 21 + BatteryLow = 22 + + UnknownInternal = -1000 + + +class FanSpeed(IntEnum): + """Fan speed level.""" + + Quiet = 1 + Standard = 2 + Turbo = 3 + Max = 4 + Ultra = 5 + + +class CleanMode(IntEnum): + """Clean mode for ``setSwitchClean`` and ``getCleanStatus``. + + Used as ``clean_mode`` in commands and ``clean_status`` in status responses. + """ + + #: Clean all rooms with uniform settings. + StandardHome = 0 + #: Clean all rooms with per-room settings and custom order. + AdvancedHome = 1 + #: Clean a small area around the vacuum's current position. + Spot = 2 + #: Clean selected rooms only. + Room = 3 + #: Clean user-defined rectangular areas. + Zone = 4 + #: Run a saved custom cleaning preset. + Custom = 5 + + +@dataclass +class CleanAreaSettings: + """Per-area cleaning settings shared by rooms and zones.""" + + #: Suction power level, or ``None`` when not configured per-area. + suction: FanSpeed | None = None + #: Water level for mopping. + cistern: int = 0 + #: Number of cleaning passes. + clean_number: int = 0 + + +@dataclass +class RoomInfo(CleanAreaSettings): + """Information about a room on the vacuum's map.""" + + #: Room ID used in cleaning commands. + id: int = 0 + #: Human-readable room name (base64-decoded from the device). + name: str | None = None + #: Color index used for map rendering. + color: int = 0 + + +class AreaType(StrEnum): + """Type of area entry in map data.""" + + #: A named room. + Room = "room" + #: A user-defined rectangular cleaning zone. + Area = "area" + #: A virtual wall boundary. + VirtualWall = "virtual_wall" + #: A no-go zone. + Forbid = "forbid" + #: A detected carpet region. + CarpetRectangle = "carpet_rectangle" + + +class AreaUnit(IntEnum): + """Area unit.""" + + #: Square meter + Sqm = 0 + #: Square feet + Sqft = 1 + #: Taiwanese unit: https://en.wikipedia.org/wiki/Taiwanese_units_of_measurement#Area + Ping = 2 + + +class Clean(SmartModule): + """Implementation of vacuum clean module.""" + + REQUIRED_COMPONENT = "clean" + _error_code = ErrorCode.Ok + _logged_error_code_warnings: set | None = None + _logged_status_code_warnings: set + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="vacuum_return_home", + name="Return home", + container=self, + attribute_setter="return_home", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_start", + name="Start cleaning", + container=self, + attribute_setter="start", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_pause", + name="Pause", + container=self, + attribute_setter="pause", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_status", + name="Vacuum status", + container=self, + attribute_getter="status", + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_error", + name="Error", + container=self, + attribute_getter="error", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="battery_level", + name="Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="vacuum_fan_speed", + name="Fan speed", + container=self, + attribute_getter="fan_speed_preset", + attribute_setter="set_fan_speed_preset", + icon="mdi:fan", + choices_getter=lambda: list(FanSpeed.__members__), + category=Feature.Category.Primary, + type=Feature.Type.Choice, + ) + ) + self._add_feature( + Feature( + self._device, + id="clean_count", + name="Clean count", + container=self, + attribute_getter="clean_count", + attribute_setter="set_clean_count", + range_getter=lambda: (1, 3), + category=Feature.Category.Config, + type=Feature.Type.Number, + ) + ) + self._add_feature( + Feature( + self._device, + id="carpet_boost", + name="Carpet boost", + container=self, + attribute_getter="carpet_boost", + attribute_setter="set_carpet_boost", + icon="mdi:rug", + category=Feature.Category.Config, + type=Feature.Type.Switch, + ) + ) + self._add_feature( + Feature( + self._device, + id="clean_area", + name="Cleaning area", + container=self, + attribute_getter="clean_area", + unit_getter="area_unit", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="clean_time", + name="Cleaning time", + container=self, + attribute_getter="clean_time", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="clean_progress", + name="Cleaning progress", + container=self, + attribute_getter="clean_progress", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + async def _post_update_hook(self) -> None: + """Set error code after update.""" + if self._logged_error_code_warnings is None: + self._logged_error_code_warnings = set() + self._logged_status_code_warnings = set() + + errors = self._vac_status.get("err_status") + if errors is None or not errors: + self._error_code = ErrorCode.Ok + return + + if len(errors) > 1 and "multiple" not in self._logged_error_code_warnings: + self._logged_error_code_warnings.add("multiple") + _LOGGER.warning( + "Multiple error codes, using the first one only: %s", errors + ) + + error = errors.pop(0) + try: + self._error_code = ErrorCode(error) + except ValueError: + if error not in self._logged_error_code_warnings: + self._logged_error_code_warnings.add(error) + _LOGGER.warning( + "Unknown error code, please create an issue " + "describing the error: %s", + error, + ) + self._error_code = ErrorCode.UnknownInternal + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getVacStatus": {}, + "getCleanInfo": {}, + "getCarpetClean": {}, + "getAreaUnit": {}, + "getBatteryInfo": {}, + "getCleanStatus": {}, + "getCleanAttr": {"type": "global"}, + "getMapInfo": {}, + } + + async def start(self) -> dict: + """Start cleaning.""" + # If we are paused, do not restart cleaning + + if self.status is Status.Paused: + return await self.resume() + + return await self.call( + "setSwitchClean", + { + "clean_mode": CleanMode.StandardHome, + "clean_on": True, + "clean_order": True, + "force_clean": False, + }, + ) + + async def pause(self) -> dict: + """Pause cleaning.""" + if self.status is Status.GoingHome: + return await self.set_return_home(False) + + return await self.set_pause(True) + + async def resume(self) -> dict: + """Resume cleaning.""" + return await self.set_pause(False) + + async def set_pause(self, enabled: bool) -> dict: + """Pause or resume cleaning.""" + return await self.call("setRobotPause", {"pause": enabled}) + + async def return_home(self) -> dict: + """Return home.""" + return await self.set_return_home(True) + + async def set_return_home(self, enabled: bool) -> dict: + """Return home / pause returning.""" + return await self.call("setSwitchCharge", {"switch_charge": enabled}) + + @property + def error(self) -> ErrorCode: + """Return error.""" + return self._error_code + + @property + def fan_speed_preset(self) -> Annotated[str, FeatureAttribute()]: + """Return fan speed preset.""" + return FanSpeed(self._settings["suction"]).name + + async def set_fan_speed_preset( + self, speed: str + ) -> Annotated[dict, FeatureAttribute]: + """Set fan speed preset.""" + name_to_value = {x.name: x.value for x in FanSpeed} + if speed not in name_to_value: + raise ValueError("Invalid fan speed %s, available %s", speed, name_to_value) + return await self._change_setting("suction", name_to_value[speed]) + + async def _change_setting( + self, name: str, value: int, *, scope: Literal["global", "pose"] = "global" + ) -> dict: + """Change device setting.""" + params = { + name: value, + "type": scope, + } + return await self.call("setCleanAttr", params) + + @property + def battery(self) -> int: + """Return battery level.""" + return self.data["getBatteryInfo"]["battery_percentage"] + + @property + def _vac_status(self) -> dict: + """Return vac status container.""" + return self.data["getVacStatus"] + + @property + def _info(self) -> dict: + """Return current cleaning info.""" + return self.data["getCleanInfo"] + + @property + def _settings(self) -> dict: + """Return cleaning settings.""" + return self.data["getCleanAttr"] + + @property + def status(self) -> Status: + """Return current status.""" + if self._error_code is not ErrorCode.Ok: + return Status.Error + + status_code = self._vac_status["status"] + try: + return Status(status_code) + except ValueError: + if status_code not in self._logged_status_code_warnings: + self._logged_status_code_warnings.add(status_code) + _LOGGER.warning( + "Got unknown status code: %s (%s)", status_code, self.data + ) + return Status.UnknownInternal + + @property + def carpet_boost(self) -> bool: + """Return carpet boost mode.""" + return self.data["getCarpetClean"]["carpet_clean_prefer"] == "boost" + + async def set_carpet_boost(self, on: bool) -> dict: + """Set carpet clean mode.""" + mode = "boost" if on else "normal" + return await self.call("setCarpetClean", {"carpet_clean_prefer": mode}) + + @property + def area_unit(self) -> AreaUnit: + """Return area unit.""" + return AreaUnit(self.data["getAreaUnit"]["area_unit"]) + + @property + def clean_area(self) -> Annotated[int, FeatureAttribute()]: + """Return currently cleaned area.""" + return self._info["clean_area"] + + @property + def clean_time(self) -> timedelta: + """Return current cleaning time.""" + return timedelta(minutes=self._info["clean_time"]) + + @property + def clean_progress(self) -> int: + """Return amount of currently cleaned area.""" + return self._info["clean_percent"] + + @property + def clean_count(self) -> Annotated[int, FeatureAttribute()]: + """Return number of times to clean.""" + return self._settings["clean_number"] + + async def set_clean_count(self, count: int) -> Annotated[dict, FeatureAttribute()]: + """Set number of times to clean.""" + return await self._change_setting("clean_number", count) + + @property + def current_map_id(self) -> int: + """Return the ID of the currently active map.""" + return self.data["getMapInfo"]["current_map_id"] + + @property + def current_map_name(self) -> str | None: + """Return the name of the currently active map, or ``None``.""" + map_info = self.data["getMapInfo"] + current_id = map_info["current_map_id"] + for m in map_info.get("map_list", []): + if m.get("map_id") == current_id: + if raw_name := m.get("map_name"): + try: + return base64.b64decode(raw_name).decode() + except Exception: + return raw_name + return None + return None + + @property + def clean_type(self) -> CleanMode | None: + """Return the active cleaning mode, or ``None`` if unavailable.""" + cs = self.data.get("getCleanStatus") + if cs is None or "clean_status" not in cs: + return None + return CleanMode(cs["clean_status"]) + + async def clean_rooms( + self, room_ids: list[int], *, map_id: int | None = None + ) -> dict: + """Start cleaning specific rooms. + + Per-room settings are not supported; the device uses the global + suction / cistern / clean_number values for room cleaning. + + :param room_ids: List of room IDs to clean. + :param map_id: Map ID to clean on. Defaults to the current active map. + """ + if not room_ids: + raise ValueError("room_ids must not be empty") + if map_id is None: + map_id = self.current_map_id + return await self.call( + "setSwitchClean", + { + "clean_mode": CleanMode.Room, + "clean_on": True, + "clean_order": True, + "force_clean": False, + "map_id": map_id, + "room_list": list(room_ids), + "start_type": _START_TYPE_DEFAULT, + }, + ) + + async def get_rooms(self, map_id: int | None = None) -> list[RoomInfo]: + """Return the list of rooms for the given map. + + Room names are base64-decoded when present. + + :param map_id: Map ID to query. Defaults to the current active map. + """ + if map_id is None: + map_id = self.current_map_id + resp = await self.call( + "getMapData", {"map_id": map_id, "type": _MAP_DATA_TYPE_DEFAULT} + ) + map_data = resp.get("getMapData", resp) + + rooms: list[RoomInfo] = [] + for area in map_data.get("area_list", []): + if area.get("type") != AreaType.Room: + _LOGGER.debug( + "Skipping non-room area: type=%s, id=%s", + area.get("type"), + area.get("id"), + ) + continue + name = None + if raw_name := area.get("name"): + try: + name = base64.b64decode(raw_name).decode() + except Exception: + name = raw_name + + suction_val = area.get("suction") + rooms.append( + RoomInfo( + id=area["id"], + name=name, + color=area.get("color", 0), + suction=FanSpeed(suction_val) if suction_val else None, + cistern=area.get("cistern", 0), + clean_number=area.get("clean_number", 0), + ) + ) + return rooms diff --git a/kasa/smart/modules/cleanrecords.py b/kasa/smart/modules/cleanrecords.py new file mode 100644 index 000000000..fdd0daeec --- /dev/null +++ b/kasa/smart/modules/cleanrecords.py @@ -0,0 +1,205 @@ +"""Implementation of vacuum cleaning records.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta, tzinfo +from typing import Annotated, cast + +from mashumaro import DataClassDictMixin, field_options +from mashumaro.config import ADD_DIALECT_SUPPORT +from mashumaro.dialect import Dialect +from mashumaro.types import SerializationStrategy + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import Module, SmartModule +from .clean import AreaUnit, Clean + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Record(DataClassDictMixin): + """Historical cleanup result.""" + + class Config: + """Configuration class.""" + + code_generation_options = [ADD_DIALECT_SUPPORT] + + #: Total time cleaned (in minutes) + clean_time: timedelta = field( + metadata=field_options(deserialize=lambda x: timedelta(minutes=x)) + ) + #: Total area cleaned + clean_area: int + dust_collection: bool + timestamp: datetime + + info_num: int | None = None + message: int | None = None + map_id: int | None = None + start_type: int | None = None + task_type: int | None = None + record_index: int | None = None + + #: Error code from cleaning + error: int = field(default=0) + + +class _DateTimeSerializationStrategy(SerializationStrategy): + def __init__(self, tz: tzinfo) -> None: + self.tz = tz + + def deserialize(self, value: float) -> datetime: + return datetime.fromtimestamp(value, self.tz) + + +def _get_tz_strategy(tz: tzinfo) -> type[Dialect]: + """Return a timezone aware de-serialization strategy.""" + + class TimezoneDialect(Dialect): + serialization_strategy = {datetime: _DateTimeSerializationStrategy(tz)} + + return TimezoneDialect + + +@dataclass +class Records(DataClassDictMixin): + """Response payload for getCleanRecords.""" + + class Config: + """Configuration class.""" + + code_generation_options = [ADD_DIALECT_SUPPORT] + + total_time: timedelta = field( + metadata=field_options(deserialize=lambda x: timedelta(minutes=x)) + ) + total_area: int + total_count: int = field(metadata=field_options(alias="total_number")) + + records: list[Record] = field(metadata=field_options(alias="record_list")) + last_clean: Record = field(metadata=field_options(alias="lastest_day_record")) + + @classmethod + def __pre_deserialize__(cls, d: dict) -> dict: + if ldr := d.get("lastest_day_record"): + d["lastest_day_record"] = { + "timestamp": ldr[0], + "clean_time": ldr[1], + "clean_area": ldr[2], + "dust_collection": ldr[3], + } + return d + + +class CleanRecords(SmartModule): + """Implementation of vacuum cleaning records.""" + + REQUIRED_COMPONENT = "clean_percent" + _parsed_data: Records + + async def _post_update_hook(self) -> None: + """Cache parsed data after an update.""" + self._parsed_data = Records.from_dict( + self.data, dialect=_get_tz_strategy(self._device.timezone) + ) + + def _initialize_features(self) -> None: + """Initialize features.""" + for type_ in ["total", "last"]: + self._add_feature( + Feature( + self._device, + id=f"{type_}_clean_area", + name=f"{type_.capitalize()} area cleaned", + container=self, + attribute_getter=f"{type_}_clean_area", + unit_getter="area_unit", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id=f"{type_}_clean_time", + name=f"{type_.capitalize()} time cleaned", + container=self, + attribute_getter=f"{type_}_clean_time", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="total_clean_count", + name="Total clean count", + container=self, + attribute_getter="total_clean_count", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="last_clean_timestamp", + name="Last clean timestamp", + container=self, + attribute_getter="last_clean_timestamp", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getCleanRecords": {}, + } + + @property + def total_clean_area(self) -> Annotated[int, FeatureAttribute()]: + """Return total cleaning area.""" + return self._parsed_data.total_area + + @property + def total_clean_time(self) -> timedelta: + """Return total cleaning time.""" + return self._parsed_data.total_time + + @property + def total_clean_count(self) -> int: + """Return total clean count.""" + return self._parsed_data.total_count + + @property + def last_clean_area(self) -> Annotated[int, FeatureAttribute()]: + """Return latest cleaning area.""" + return self._parsed_data.last_clean.clean_area + + @property + def last_clean_time(self) -> timedelta: + """Return total cleaning time.""" + return self._parsed_data.last_clean.clean_time + + @property + def last_clean_timestamp(self) -> datetime: + """Return latest cleaning timestamp.""" + return self._parsed_data.last_clean.timestamp + + @property + def area_unit(self) -> AreaUnit: + """Return area unit.""" + clean = cast(Clean, self._device.modules[Module.Clean]) + return clean.area_unit + + @property + def parsed_data(self) -> Records: + """Return parsed records data.""" + return self._parsed_data diff --git a/kasa/smart/modules/consumables.py b/kasa/smart/modules/consumables.py new file mode 100644 index 000000000..10de583e8 --- /dev/null +++ b/kasa/smart/modules/consumables.py @@ -0,0 +1,170 @@ +"""Implementation of vacuum consumables.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timedelta + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class _ConsumableMeta: + """Consumable meta container.""" + + #: Name of the consumable. + name: str + #: Internal id of the consumable + id: str + #: Data key in the device reported data + data_key: str + #: Lifetime + lifetime: timedelta + + +@dataclass +class Consumable: + """Consumable container.""" + + #: Name of the consumable. + name: str + #: Id of the consumable + id: str + #: Lifetime + lifetime: timedelta + #: Used + used: timedelta + #: Remaining + remaining: timedelta + #: Device data key + _data_key: str + + +CONSUMABLE_METAS = [ + _ConsumableMeta( + "Main brush", + id="main_brush", + data_key="roll_brush_time", + lifetime=timedelta(hours=400), + ), + _ConsumableMeta( + "Side brush", + id="side_brush", + data_key="edge_brush_time", + lifetime=timedelta(hours=200), + ), + _ConsumableMeta( + "Filter", + id="filter", + data_key="filter_time", + lifetime=timedelta(hours=200), + ), + _ConsumableMeta( + "Sensor", + id="sensor", + data_key="sensor_time", + lifetime=timedelta(hours=30), + ), + _ConsumableMeta( + "Charging contacts", + id="charging_contacts", + data_key="charge_contact_time", + lifetime=timedelta(hours=30), + ), + # Unknown keys: main_brush_lid_time, rag_time +] + + +class Consumables(SmartModule): + """Implementation of vacuum consumables.""" + + REQUIRED_COMPONENT = "consumables" + QUERY_GETTER_NAME = "getConsumablesInfo" + + _consumables: dict[str, Consumable] = {} + + def _initialize_features(self) -> None: + """Initialize features.""" + for c_meta in CONSUMABLE_METAS: + if c_meta.data_key not in self.data: + continue + + self._add_feature( + Feature( + self._device, + id=f"{c_meta.id}_used", + name=f"{c_meta.name} used", + container=self, + attribute_getter=lambda _, c_id=c_meta.id: self._consumables[ + c_id + ].used, + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id=f"{c_meta.id}_remaining", + name=f"{c_meta.name} remaining", + container=self, + attribute_getter=lambda _, c_id=c_meta.id: self._consumables[ + c_id + ].remaining, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id=f"{c_meta.id}_reset", + name=f"Reset {c_meta.name.lower()} consumable", + container=self, + attribute_setter=lambda c_id=c_meta.id: self.reset_consumable(c_id), + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + + async def _post_update_hook(self) -> None: + """Update the consumables.""" + if not self._consumables: + for consumable_meta in CONSUMABLE_METAS: + if consumable_meta.data_key not in self.data: + continue + used = timedelta(minutes=self.data[consumable_meta.data_key]) + consumable = Consumable( + id=consumable_meta.id, + name=consumable_meta.name, + lifetime=consumable_meta.lifetime, + used=used, + remaining=consumable_meta.lifetime - used, + _data_key=consumable_meta.data_key, + ) + self._consumables[consumable_meta.id] = consumable + else: + for consumable in self._consumables.values(): + consumable.used = timedelta(minutes=self.data[consumable._data_key]) + consumable.remaining = consumable.lifetime - consumable.used + + async def reset_consumable(self, consumable_id: str) -> dict: + """Reset consumable stats.""" + consumable_name = self._consumables[consumable_id]._data_key.removesuffix( + "_time" + ) + return await self.call( + "resetConsumablesTime", {"reset_list": [consumable_name]} + ) + + @property + def consumables(self) -> Mapping[str, Consumable]: + """Get list of consumables on the device.""" + return self._consumables diff --git a/kasa/smart/modules/contactsensor.py b/kasa/smart/modules/contactsensor.py index f388b781d..d0bebb077 100644 --- a/kasa/smart/modules/contactsensor.py +++ b/kasa/smart/modules/contactsensor.py @@ -10,7 +10,7 @@ class ContactSensor(SmartModule): """Implementation of contact sensor module.""" REQUIRED_COMPONENT = None # we depend on availability of key - REQUIRED_KEY_ON_PARENT = "open" + SYSINFO_LOOKUP_KEYS = ["open"] def _initialize_features(self) -> None: """Initialize features after the initial update.""" diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index bf112e2dd..692745bb4 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -19,12 +19,15 @@ async def _post_update_hook(self) -> None: def query(self) -> dict: """Query to execute during the update cycle.""" + if self._device._is_hub_child: + # Child devices get their device info updated by the parent device. + return {} query = { "get_device_info": None, } # Device usage is not available on older firmware versions # or child devices of hubs - if self.supported_version >= 2 and not self._device._is_hub_child: + if self.supported_version >= 2: query["get_device_usage"] = None return query diff --git a/kasa/smart/modules/dustbin.py b/kasa/smart/modules/dustbin.py new file mode 100644 index 000000000..b2b4d1ef4 --- /dev/null +++ b/kasa/smart/modules/dustbin.py @@ -0,0 +1,127 @@ +"""Implementation of vacuum dustbin.""" + +from __future__ import annotations + +import logging +from enum import IntEnum + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Mode(IntEnum): + """Dust collection modes.""" + + Smart = 0 + Light = 1 + Balanced = 2 + Max = 3 + + Off = -1_000 + + +class Dustbin(SmartModule): + """Implementation of vacuum dustbin.""" + + REQUIRED_COMPONENT = "dust_bucket" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="dustbin_empty", + name="Empty dustbin", + container=self, + attribute_setter="start_emptying", + category=Feature.Category.Config, + type=Feature.Action, + ) + ) + + self._add_feature( + Feature( + self._device, + id="dustbin_autocollection_enabled", + name="Automatic emptying enabled", + container=self, + attribute_getter="auto_collection", + attribute_setter="set_auto_collection", + category=Feature.Category.Config, + type=Feature.Switch, + ) + ) + + self._add_feature( + Feature( + self._device, + id="dustbin_mode", + name="Automatic emptying mode", + container=self, + attribute_getter="mode", + attribute_setter="set_mode", + icon="mdi:fan", + choices_getter=lambda: list(Mode.__members__), + category=Feature.Category.Config, + type=Feature.Type.Choice, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getAutoDustCollection": {}, + "getDustCollectionInfo": {}, + } + + async def start_emptying(self) -> dict: + """Start emptying the bin.""" + return await self.call( + "setSwitchDustCollection", + { + "switch_dust_collection": True, + }, + ) + + @property + def _settings(self) -> dict: + """Return auto-empty settings.""" + return self.data["getDustCollectionInfo"] + + @property + def mode(self) -> str: + """Return auto-emptying mode.""" + if self.auto_collection is False: + return Mode.Off.name + return Mode(self._settings["dust_collection_mode"]).name + + async def set_mode(self, mode: str) -> dict: + """Set auto-emptying mode.""" + name_to_value = {x.name: x.value for x in Mode} + if mode not in name_to_value: + raise ValueError( + "Invalid auto/emptying mode speed %s, available %s", mode, name_to_value + ) + + if mode == Mode.Off.name: + return await self.set_auto_collection(False) + + # Make a copy just in case, even when we are overriding both settings + settings = self._settings.copy() + settings["auto_dust_collection"] = True + settings["dust_collection_mode"] = name_to_value[mode] + + return await self.call("setDustCollectionInfo", settings) + + @property + def auto_collection(self) -> dict: + """Return auto-emptying config.""" + return self._settings["auto_dust_collection"] + + async def set_auto_collection(self, on: bool) -> dict: + """Toggle auto-emptying.""" + settings = self._settings.copy() + settings["auto_dust_collection"] = on + return await self.call("setDustCollectionInfo", settings) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 6b5bdb579..b898f38e2 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -2,25 +2,70 @@ from __future__ import annotations -from typing import NoReturn +from typing import Any, NoReturn from ...emeterstatus import EmeterStatus -from ...exceptions import KasaException +from ...exceptions import DeviceError, KasaException, SmartErrorCode from ...interfaces.energy import Energy as EnergyInterface from ..smartmodule import SmartModule, raise_if_update_error +_OPTIONAL_METHOD_ERRORS = { + SmartErrorCode.PARAMS_ERROR, + SmartErrorCode.UNKNOWN_METHOD_ERROR, +} + class Energy(SmartModule, EnergyInterface): """Implementation of energy monitoring module.""" REQUIRED_COMPONENT = "energy_monitoring" + _energy: dict[str, Any] + _current_consumption: float | None + + def _get_current_power_mw( + self, data: dict[str, Any], energy: dict[str, Any] | None = None + ) -> float | None: + """Return the best available current power reading in milliwatts.""" + energy = self._energy if energy is None else energy + + if (power := data.get("get_emeter_data", {}).get("power_mw")) is not None: + return power + + # Prefer the higher precision milliwatt readings from the energy usage + # payload. get_current_power is only a lower precision fallback used by + # devices such as P304M whose get_energy_usage omits current_power. + if (power := energy.get("current_power")) is not None: + return power + + if ( + power := data.get("get_current_power", {}).get("current_power") + ) is not None: + return power * 1_000 + + return None + async def _post_update_hook(self) -> None: - if "voltage_mv" in self.data.get("get_emeter_data", {}): + try: + data = self.data + except DeviceError as de: + self._energy = {} + self._current_consumption = None + raise de + + # If version is 1 then data is get_energy_usage + self._energy = data.get("get_energy_usage", data) + + if "voltage_mv" in data.get("get_emeter_data", {}): self._supported = ( self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT ) + if (power := self._get_current_power_mw(data)) is not None: + self._current_consumption = power / 1_000 + else: + self._current_consumption = None + def query(self) -> dict: """Query to execute during the update cycle.""" req = { @@ -33,33 +78,30 @@ def query(self) -> dict: return req @property - @raise_if_update_error + def optional_response_keys(self) -> list[str]: + """Return optional response keys for the module.""" + if self.supported_version > 1: + return ["get_energy_usage", "get_current_power"] + return [] + + @property def current_consumption(self) -> float | None: """Current power in watts.""" - if (power := self.energy.get("current_power")) is not None or ( - power := self.data.get("get_emeter_data", {}).get("power_mw") - ) is not None: - return power / 1_000 - # Fallback if get_energy_usage does not provide current_power, - # which can happen on some newer devices (e.g. P304M). - elif ( - power := self.data.get("get_current_power", {}).get("current_power") - ) is not None: - return power - return None + return self._current_consumption @property - @raise_if_update_error def energy(self) -> dict: """Return get_energy_usage results.""" - if en := self.data.get("get_energy_usage"): - return en - return self.data + return self._energy - def _get_status_from_energy(self, energy: dict) -> EmeterStatus: + def _get_status_from_energy( + self, energy: dict[str, Any], power_mw: float | None = None + ) -> EmeterStatus: return EmeterStatus( { - "power_mw": energy.get("current_power", 0), + "power_mw": ( + power_mw if power_mw is not None else energy.get("current_power") + ), "total": energy.get("today_energy", 0) / 1_000, } ) @@ -68,31 +110,64 @@ def _get_status_from_energy(self, energy: dict) -> EmeterStatus: @raise_if_update_error def status(self) -> EmeterStatus: """Get the emeter status.""" - if "get_emeter_data" in self.data: - return EmeterStatus(self.data["get_emeter_data"]) - else: - return self._get_status_from_energy(self.energy) + data = self.data + if "get_emeter_data" in data: + return EmeterStatus(data["get_emeter_data"]) + + return self._get_status_from_energy( + self.energy, self._get_current_power_mw(data) + ) async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" - if "get_emeter_data" in self.data: - res = await self.call("get_emeter_data") - return EmeterStatus(res["get_emeter_data"]) - else: + if self.supported_version > 1: + try: + res = await self.call("get_emeter_data") + except DeviceError as ex: + if ex.error_code not in _OPTIONAL_METHOD_ERRORS: + raise + else: + return EmeterStatus(res["get_emeter_data"]) + + energy: dict[str, Any] = {} + try: res = await self.call("get_energy_usage") - return self._get_status_from_energy(res["get_energy_usage"]) + except DeviceError: + if self.supported_version <= 1: + raise + else: + energy = res["get_energy_usage"] + if energy.get("current_power") is not None: + return self._get_status_from_energy(energy) + + current_power: dict[str, Any] = {} + if self.supported_version > 1: + try: + res = await self.call("get_current_power") + except DeviceError as ex: + if ex.error_code not in _OPTIONAL_METHOD_ERRORS: + raise + else: + current_power = res["get_current_power"] + + return self._get_status_from_energy( + energy, + self._get_current_power_mw({"get_current_power": current_power}, energy), + ) @property - @raise_if_update_error def consumption_this_month(self) -> float | None: """Get the emeter value for this month in kWh.""" - return self.energy.get("month_energy", 0) / 1_000 + if (month := self.energy.get("month_energy")) is not None: + return month / 1_000 + return None @property - @raise_if_update_error def consumption_today(self) -> float | None: """Get the emeter value for today in kWh.""" - return self.energy.get("today_energy", 0) / 1_000 + if (today := self.energy.get("today_energy")) is not None: + return today / 1_000 + return None @property @raise_if_update_error @@ -104,15 +179,17 @@ def consumption_total(self) -> float | None: @raise_if_update_error def current(self) -> float | None: """Return the current in A.""" - ma = self.data.get("get_emeter_data", {}).get("current_ma") - return ma / 1000 if ma else None + if (ma := self.data.get("get_emeter_data", {}).get("current_ma")) is not None: + return ma / 1_000 + return None @property @raise_if_update_error def voltage(self) -> float | None: """Get the current voltage in V.""" - mv = self.data.get("get_emeter_data", {}).get("voltage_mv") - return mv / 1000 if mv else None + if (mv := self.data.get("get_emeter_data", {}).get("voltage_mv")) is not None: + return mv / 1_000 + return None async def _deprecated_get_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" diff --git a/kasa/smart/modules/homekit.py b/kasa/smart/modules/homekit.py new file mode 100644 index 000000000..2df8db1f5 --- /dev/null +++ b/kasa/smart/modules/homekit.py @@ -0,0 +1,32 @@ +"""Implementation of homekit module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class HomeKit(SmartModule): + """Implementation of homekit module.""" + + QUERY_GETTER_NAME: str = "get_homekit_info" + REQUIRED_COMPONENT = "homekit" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="homekit_setup_code", + name="Homekit setup code", + container=self, + attribute_getter=lambda x: x.info["mfi_setup_code"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + @property + def info(self) -> dict[str, str]: + """Homekit mfi setup info.""" + return self.data diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 730988750..d548811f5 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -7,7 +7,7 @@ from ...exceptions import KasaException from ...feature import Feature -from ...interfaces.light import HSV, ColorTempRange, LightState +from ...interfaces.light import HSV, LightState from ...interfaces.light import Light as LightInterface from ...module import FeatureAttribute, Module from ..smartmodule import SmartModule @@ -34,39 +34,13 @@ def query(self) -> dict: """Query to execute during the update cycle.""" return {} - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return Module.Color in self._device.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return Module.Brightness in self._device.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return Module.ColorTemperature in self._device.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return self._device.modules[Module.ColorTemperature].valid_temperature_range - @property def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if Module.Color not in self._device.modules: raise KasaException("Bulb does not support color.") return self._device.modules[Module.Color].hsv @@ -74,7 +48,7 @@ def hsv(self) -> Annotated[HSV, FeatureAttribute()]: @property def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Bulb does not support colortemp.") return self._device.modules[Module.ColorTemperature].color_temp @@ -82,7 +56,7 @@ def color_temp(self) -> Annotated[int, FeatureAttribute()]: @property def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if Module.Brightness not in self._device.modules: # pragma: no cover raise KasaException("Bulb is not dimmable.") return self._device.modules[Module.Brightness].brightness @@ -104,7 +78,7 @@ async def set_hsv( :param int value: value between 1 and 100 :param int transition: transition in milliseconds. """ - if not self.is_color: + if Module.Color not in self._device.modules: raise KasaException("Bulb does not support color.") return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) @@ -119,7 +93,7 @@ async def set_color_temp( :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Bulb does not support colortemp.") return await self._device.modules[Module.ColorTemperature].set_color_temp( temp, brightness=brightness @@ -135,16 +109,11 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if Module.Brightness not in self._device.modules: # pragma: no cover raise KasaException("Bulb is not dimmable.") return await self._device.modules[Module.Brightness].set_brightness(brightness) - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return Module.LightEffect in self._device.modules - async def set_state(self, state: LightState) -> dict: """Set the light state.""" state_dict = asdict(state) @@ -167,16 +136,17 @@ def state(self) -> LightState: return self._light_state async def _post_update_hook(self) -> None: - if self._device.is_on is False: + device = self._device + if device.is_on is False: state = LightState(light_on=False) else: state = LightState(light_on=True) - if self.is_dimmable: + if Module.Brightness in device.modules: state.brightness = self.brightness - if self.is_color: + if Module.Color in device.modules: hsv = self.hsv state.hue = hsv.hue state.saturation = hsv.saturation - if self.is_variable_color_temp: + if Module.ColorTemperature in device.modules: state.color_temp = self.color_temp self._light_state = state diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 2eba75725..87e96eaee 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -96,13 +96,18 @@ def preset(self) -> str: """Return current preset name.""" light = self._device.modules[SmartModule.Light] brightness = light.brightness - color_temp = light.color_temp if light.is_variable_color_temp else None - h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + color_temp = light.color_temp if light.has_feature("color_temp") else None + h, s = ( + (light.hsv.hue, light.hsv.saturation) + if light.has_feature("hsv") + else (None, None) + ) for preset_name, preset in self._presets.items(): if ( preset.brightness == brightness and ( - preset.color_temp == color_temp or not light.is_variable_color_temp + preset.color_temp == color_temp + or not light.has_feature("color_temp") ) and preset.hue == h and preset.saturation == s @@ -117,7 +122,7 @@ async def set_preset( """Set a light preset for the device.""" light = self._device.modules[SmartModule.Light] if preset_name == self.PRESET_NOT_SET: - if light.is_color: + if light.has_feature("hsv"): preset = LightState(hue=0, saturation=0, brightness=100) else: preset = LightState(brightness=100) diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 91d891887..34c1c20c2 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -37,20 +37,14 @@ def name(self) -> str: @property def effect(self) -> str: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ + """Return effect name.""" eff = self.data["lighting_effect"] name = eff["name"] # When devices are unpaired effect name is softAP which is not in our list if eff["enable"] and name in self._effect_list: return name + if eff["enable"] and eff["custom"]: + return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM return self.LIGHT_EFFECTS_OFF @property diff --git a/kasa/smart/modules/matter.py b/kasa/smart/modules/matter.py new file mode 100644 index 000000000..c6bfe2d85 --- /dev/null +++ b/kasa/smart/modules/matter.py @@ -0,0 +1,43 @@ +"""Implementation of matter module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class Matter(SmartModule): + """Implementation of matter module.""" + + QUERY_GETTER_NAME: str = "get_matter_setup_info" + REQUIRED_COMPONENT = "matter" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="matter_setup_code", + name="Matter setup code", + container=self, + attribute_getter=lambda x: x.info["setup_code"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + self._device, + id="matter_setup_payload", + name="Matter setup payload", + container=self, + attribute_getter=lambda x: x.info["setup_payload"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + @property + def info(self) -> dict[str, str]: + """Matter setup info.""" + return self.data diff --git a/kasa/smart/modules/mop.py b/kasa/smart/modules/mop.py new file mode 100644 index 000000000..cc6cb84fc --- /dev/null +++ b/kasa/smart/modules/mop.py @@ -0,0 +1,91 @@ +"""Implementation of vacuum mop.""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Waterlevel(IntEnum): + """Water level for mopping.""" + + Disable = 0 + Low = 1 + Medium = 2 + High = 3 + + +class Mop(SmartModule): + """Implementation of vacuum mop.""" + + REQUIRED_COMPONENT = "mop" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="mop_attached", + name="Mop attached", + container=self, + icon="mdi:square-rounded", + attribute_getter="mop_attached", + category=Feature.Category.Info, + type=Feature.BinarySensor, + ) + ) + + self._add_feature( + Feature( + self._device, + id="mop_waterlevel", + name="Mop water level", + container=self, + attribute_getter="waterlevel", + attribute_setter="set_waterlevel", + icon="mdi:water", + choices_getter=lambda: list(Waterlevel.__members__), + category=Feature.Category.Config, + type=Feature.Type.Choice, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getMopState": {}, + "getCleanAttr": {"type": "global"}, + } + + @property + def mop_attached(self) -> bool: + """Return True if mop is attached.""" + return self.data["getMopState"]["mop_state"] + + @property + def _settings(self) -> dict: + """Return settings settings.""" + return self.data["getCleanAttr"] + + @property + def waterlevel(self) -> Annotated[str, FeatureAttribute()]: + """Return water level.""" + return Waterlevel(int(self._settings["cistern"])).name + + async def set_waterlevel(self, mode: str) -> Annotated[dict, FeatureAttribute()]: + """Set waterlevel mode.""" + name_to_value = {x.name: x.value for x in Waterlevel} + if mode not in name_to_value: + raise ValueError("Invalid waterlevel %s, available %s", mode, name_to_value) + + return await self.call( + "setCleanAttr", + {"cistern": name_to_value[mode], "type": "global"}, + ) diff --git a/kasa/smart/modules/overheatprotection.py b/kasa/smart/modules/overheatprotection.py new file mode 100644 index 000000000..cdaba4e82 --- /dev/null +++ b/kasa/smart/modules/overheatprotection.py @@ -0,0 +1,41 @@ +"""Overheat module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class OverheatProtection(SmartModule): + """Implementation for overheat_protection.""" + + SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + container=self, + id="overheated", + name="Overheated", + attribute_getter="overheated", + icon="mdi:heat-wave", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + + @property + def overheated(self) -> bool: + """Return True if device reports overheating.""" + if (value := self._device.sys_info.get("overheat_status")) is not None: + # Value can be normal, cooldown, or overheated. + # We report all but normal as overheated. + return value != "normal" + + return self._device.sys_info["overheated"] + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} diff --git a/kasa/smart/modules/powerprotection.py b/kasa/smart/modules/powerprotection.py new file mode 100644 index 000000000..9e6498ced --- /dev/null +++ b/kasa/smart/modules/powerprotection.py @@ -0,0 +1,129 @@ +"""Power protection module.""" + +from __future__ import annotations + +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + + +class PowerProtection(SmartModule): + """Implementation for power_protection.""" + + REQUIRED_COMPONENT = "power_protection" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="overloaded", + name="Overloaded", + container=self, + attribute_getter="overloaded", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + self._add_feature( + Feature( + device=self._device, + id="power_protection_threshold", + name="Power protection threshold", + container=self, + attribute_getter="_threshold_or_zero", + attribute_setter="_set_threshold_auto_enable", + unit_getter=lambda: "W", + type=Feature.Type.Number, + range_getter=lambda: (0, self._max_power), + category=Feature.Category.Config, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {"get_protection_power": {}, "get_max_power": {}} + + @property + def overloaded(self) -> bool: + """Return True is power protection has been triggered. + + This value remains True until the device is turned on again. + """ + return self._device.sys_info["power_protection_status"] == "overloaded" + + @property + def enabled(self) -> bool: + """Return True if child protection is enabled.""" + settings = self.data["get_protection_power"] + enabled_key = next(k for k in settings if "enabled" in k) + return settings[enabled_key] + + async def set_enabled(self, enabled: bool, *, threshold: int | None = None) -> dict: + """Set power protection enabled. + + If power protection has never been enabled before the threshold will + be 0 so if threshold is not provided it will be set to half the max. + """ + if threshold is None and enabled and self.protection_threshold == 0: + threshold = int(self._max_power / 2) + + if threshold and (threshold < 0 or threshold > self._max_power): + raise ValueError( + "Threshold out of range: %s (%s)", threshold, self.protection_threshold + ) + + enabled_key = next( + k for k in self.data["get_protection_power"] if "enabled" in k + ) + params = {**self.data["get_protection_power"], enabled_key: enabled} + if threshold is not None: + params["protection_power"] = threshold + return await self.call("set_protection_power", params) + + async def _set_threshold_auto_enable(self, threshold: int) -> dict: + """Set power protection and enable.""" + if threshold == 0: + return await self.set_enabled(False) + else: + return await self.set_enabled(True, threshold=threshold) + + @property + def _threshold_or_zero(self) -> int: + """Get power protection threshold. 0 if not enabled.""" + return self.protection_threshold if self.enabled else 0 + + @property + def _max_power(self) -> int: + """Return max power.""" + return self.data["get_max_power"]["max_power"] + + @property + def protection_threshold( + self, + ) -> Annotated[int, FeatureAttribute("power_protection_threshold")]: + """Return protection threshold in watts.""" + # If never configured, there is no value set. + return self.data["get_protection_power"].get("protection_power", 0) + + async def set_protection_threshold(self, threshold: int) -> dict: + """Set protection threshold.""" + if threshold < 0 or threshold > self._max_power: + raise ValueError( + "Threshold out of range: %s (%s)", threshold, self.protection_threshold + ) + + params = { + **self.data["get_protection_power"], + "protection_power": threshold, + } + return await self.call("set_protection_power", params) + + async def _check_supported(self) -> bool: + """Return True if module is supported. + + This is needed, as strips like P304M report the status only for children. + """ + return "power_protection_status" in self._device.sys_info diff --git a/kasa/smart/modules/speaker.py b/kasa/smart/modules/speaker.py new file mode 100644 index 000000000..e36758b40 --- /dev/null +++ b/kasa/smart/modules/speaker.py @@ -0,0 +1,67 @@ +"""Implementation of vacuum speaker.""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class Speaker(SmartModule): + """Implementation of vacuum speaker.""" + + REQUIRED_COMPONENT = "speaker" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="locate", + name="Locate device", + container=self, + attribute_setter="locate", + category=Feature.Category.Primary, + type=Feature.Action, + ) + ) + self._add_feature( + Feature( + self._device, + id="volume", + name="Volume", + container=self, + attribute_getter="volume", + attribute_setter="set_volume", + range_getter=lambda: (0, 100), + category=Feature.Category.Config, + type=Feature.Type.Number, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "getVolume": None, + } + + @property + def volume(self) -> Annotated[str, FeatureAttribute()]: + """Return volume.""" + return self.data["volume"] + + async def set_volume(self, volume: int) -> Annotated[dict, FeatureAttribute()]: + """Set volume.""" + if volume < 0 or volume > 100: + raise ValueError("Volume must be between 0 and 100") + + return await self.call("setVolume", {"volume": volume}) + + async def locate(self) -> dict: + """Play sound to locate the device.""" + return await self.call("playSelectAudio", {"audio_type": "seek_me"}) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 5b0804614..b8db7b84f 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -93,12 +93,14 @@ def mode(self) -> ThermostatState: states = self.states + # Discard known non-mode states + states.discard("low_battery") + # If the states is empty, the device is idling if not states: return ThermostatState.Idle - # Discard known extra states, and report on unknown extra states - states.discard("low_battery") + # Report on unknown extra states if len(states) > 1: _LOGGER.warning("Got multiple states: %s", states) diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py index b6f010174..fbf9e4040 100644 --- a/kasa/smart/modules/waterleaksensor.py +++ b/kasa/smart/modules/waterleaksensor.py @@ -4,8 +4,10 @@ from datetime import datetime from enum import Enum +from typing import TYPE_CHECKING from ...feature import Feature +from ...interfaces.time import Time from ..smartmodule import Module, SmartModule @@ -76,6 +78,17 @@ def alert(self) -> bool: """Return true if alarm is active.""" return self._device.sys_info["in_alarm"] + @property + def _time_module(self) -> Time: + """Return time module from the parent for timestamp calculation.""" + parent = self._device.parent + if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + assert isinstance(parent, SmartDevice) + + return parent.modules[Module.Time] + @property def alert_timestamp(self) -> datetime | None: """Return timestamp of the last leak trigger.""" @@ -84,5 +97,5 @@ def alert_timestamp(self) -> datetime | None: return None ts = self._device.sys_info["trigger_timestamp"] - tz = self._device.modules[Module.Time].timezone + tz = self._time_module.timezone return datetime.fromtimestamp(ts, tz=tz) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index db3319f3c..f4e01adad 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -6,10 +6,11 @@ import time from typing import Any +from ..device import DeviceInfo from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper -from .smartdevice import SmartDevice +from .smartdevice import ComponentsRaw, SmartDevice from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -23,6 +24,8 @@ class SmartChildDevice(SmartDevice): CHILD_DEVICE_TYPE_MAP = { "plug.powerstrip.sub-plug": DeviceType.Plug, + "plug.powerstrip.sub-bulb": DeviceType.Bulb, + "subg.plugswitch.switch": DeviceType.WallSwitch, "subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor, @@ -37,7 +40,7 @@ def __init__( self, parent: SmartDevice, info: dict, - component_info: dict, + component_info_raw: ComponentsRaw, *, config: DeviceConfig | None = None, protocol: SmartProtocol | None = None, @@ -47,7 +50,24 @@ def __init__( super().__init__(parent.host, config=parent.config, protocol=_protocol) self._parent = parent self._update_internal_state(info) - self._components = component_info + self._components_raw = component_info_raw + self._components = self._parse_components(self._components_raw) + + @property + def device_info(self) -> DeviceInfo: + """Return device info. + + Child device does not have it info and components in _last_update so + this overrides the base implementation to call _get_device_info with + info and components combined as they would be in _last_update. + """ + return self._get_device_info( + { + "get_device_info": self._info, + "component_nego": self._components_raw, + }, + None, + ) async def update(self, update_children: bool = True) -> None: """Update child module info. @@ -67,11 +87,22 @@ async def _update(self, update_children: bool = True) -> None: module_queries: list[SmartModule] = [] req: dict[str, Any] = {} for module in self.modules.values(): - if module.disabled is False and (mod_query := module.query()): + if ( + module.disabled is False + and (mod_query := module.query()) + and module._should_update(now) + ): module_queries.append(module) req.update(mod_query) if req: - self._last_update = await self.protocol.query(req) + first_update = self._last_update != {} + try: + resp = await self.protocol.query(req) + except Exception as ex: + resp = await self._handle_modular_update_error( + ex, first_update, ", ".join(mod.name for mod in module_queries), req + ) + self._last_update = resp for module in self.modules.values(): await self._handle_module_post_update( @@ -79,12 +110,17 @@ async def _update(self, update_children: bool = True) -> None: ) self._last_update_time = now + # We can first initialize the features after the first update. + # We make here an assumption that every device has at least a single feature. + if not self._features: + await self._initialize_features() + @classmethod async def create( cls, parent: SmartDevice, child_info: dict, - child_components: dict, + child_components_raw: ComponentsRaw, protocol: SmartProtocol | None = None, *, last_update: dict | None = None, @@ -97,7 +133,7 @@ async def create( derived from the parent. """ child: SmartChildDevice = cls( - parent, child_info, child_components, protocol=protocol + parent, child_info, child_components_raw, protocol=protocol ) if last_update: child._last_update = last_update diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 2ded9f144..6be2392ce 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,11 +5,12 @@ import base64 import logging import time -from collections.abc import Mapping, Sequence +from collections import OrderedDict +from collections.abc import Sequence from datetime import UTC, datetime, timedelta, tzinfo -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypeAlias, cast -from ..device import Device, WifiNetwork, _DeviceInfo +from ..device import Device, DeviceInfo, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode @@ -40,6 +41,8 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +ComponentsRaw: TypeAlias = dict[str, list[dict[str, int | str]]] + # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -61,16 +64,18 @@ def __init__( ) super().__init__(host=host, config=config, protocol=_protocol) self.protocol: SmartProtocol - self._components_raw: dict[str, Any] | None = None + self._components_raw: ComponentsRaw | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str | ModuleName[Module], SmartModule] = {} + self._modules: OrderedDict[str | ModuleName[Module], SmartModule] = ( + OrderedDict() + ) self._parent: SmartDevice | None = None - self._children: Mapping[str, SmartDevice] = {} - self._last_update = {} + self._children: dict[str, SmartDevice] = {} self._last_update_time: float | None = None self._on_since: datetime | None = None self._info: dict[str, Any] = {} + self._logged_missing_child_ids: set[str] = set() async def _initialize_children(self) -> None: """Initialize children for power strips.""" @@ -81,25 +86,86 @@ async def _initialize_children(self) -> None: resp = await self.protocol.query(child_info_query) self.internal_state.update(resp) - children = self.internal_state["get_child_device_list"]["child_device_list"] - children_components = { - child["device_id"]: { - comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] - } - for child in self.internal_state["get_child_device_component_list"][ - "child_component_list" - ] - } + async def _try_create_child( + self, info: dict, child_components: dict + ) -> SmartDevice | None: from .smartchilddevice import SmartChildDevice - self._children = { - child_info["device_id"]: await SmartChildDevice.create( - parent=self, - child_info=child_info, - child_components=children_components[child_info["device_id"]], - ) - for child_info in children + return await SmartChildDevice.create( + parent=self, + child_info=info, + child_components_raw=child_components, + ) + + async def _create_delete_children( + self, + child_device_resp: dict[str, list], + child_device_components_resp: dict[str, list], + ) -> bool: + """Create and delete children. Return True if children changed. + + Adds newly found children and deletes children that are no longer + reported by the device. It will only log once per child_id that + can't be created to avoid spamming the logs on every update. + """ + changed = False + smart_children_components = { + child["device_id"]: child + for child in child_device_components_resp["child_component_list"] } + children = self._children + child_ids: set[str] = set() + existing_child_ids = set(self._children.keys()) + + for info in child_device_resp["child_device_list"]: + if (child_id := info.get("device_id")) and ( + child_components := smart_children_components.get(child_id) + ): + child_ids.add(child_id) + + if child_id in existing_child_ids: + continue + + child = await self._try_create_child(info, child_components) + if child: + _LOGGER.debug("Created child device %s for %s", child, self.host) + changed = True + children[child_id] = child + continue + + if child_id not in self._logged_missing_child_ids: + self._logged_missing_child_ids.add(child_id) + _LOGGER.debug("Child device type not supported: %s", info) + continue + + if child_id: + if child_id not in self._logged_missing_child_ids: + self._logged_missing_child_ids.add(child_id) + _LOGGER.debug( + "Could not find child components for device %s, " + "child_id %s, components: %s: ", + self.host, + child_id, + smart_children_components, + ) + continue + + # If we couldn't get a child device id we still only want to + # log once to avoid spamming the logs on every update cycle + # so store it under an empty string + if "" not in self._logged_missing_child_ids: + self._logged_missing_child_ids.add("") + _LOGGER.debug( + "Could not find child id for device %s, info: %s", self.host, info + ) + + removed_ids = existing_child_ids - child_ids + for removed_id in removed_ids: + changed = True + removed = children.pop(removed_id) + _LOGGER.debug("Removed child device %s from %s", removed, self.host) + + return changed @property def children(self) -> Sequence[SmartDevice]: @@ -131,6 +197,13 @@ def _try_get_response( f"{request} not found in {responses} for device {self.host}" ) + @staticmethod + def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: + return { + str(comp["id"]): int(comp["ver_code"]) + for comp in components_raw["component_list"] + } + async def _negotiate(self) -> None: """Perform initialization. @@ -151,36 +224,41 @@ async def _negotiate(self) -> None: self._info = self._try_get_response(resp, "get_device_info") # Create our internal presentation of available components - self._components_raw = cast(dict, resp["component_nego"]) + self._components_raw = cast(ComponentsRaw, resp["component_nego"]) - self._components = { - comp["id"]: int(comp["ver_code"]) - for comp in self._components_raw["component_list"] - } + self._components = self._parse_components(self._components_raw) if "child_device" in self._components and not self.children: await self._initialize_children() - def _update_children_info(self) -> None: - """Update the internal child device info from the parent info.""" + async def _update_children_info(self) -> bool: + """Update the internal child device info from the parent info. + + Return true if children added or deleted. + """ + changed = False if child_info := self._try_get_response( self._last_update, "get_child_device_list", {} ): + changed = await self._create_delete_children( + child_info, self._last_update["get_child_device_component_list"] + ) + for info in child_info["child_device_list"]: - child_id = info["device_id"] + child_id = info.get("device_id") if child_id not in self._children: - _LOGGER.debug( - "Skipping child update for %s, probably unsupported device", - child_id, - ) + # _create_delete_children has already logged a message continue + self._children[child_id]._update_internal_state(info) + return changed + def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" self._info = self._try_get_response(info_resp, "get_device_info") - async def update(self, update_children: bool = False) -> None: + async def update(self, update_children: bool = True) -> None: """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") @@ -198,13 +276,13 @@ async def update(self, update_children: bool = False) -> None: resp = await self._modular_update(first_update, now) - self._update_children_info() + children_changed = await self._update_children_info() # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other # devices will always update children to prevent errors on module access. # This needs to go after updating the internal state of the children so that # child modules have access to their sysinfo. - if update_children or self.device_type != DeviceType.Hub: + if children_changed or update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): if TYPE_CHECKING: assert isinstance(child, SmartChildDevice) @@ -257,11 +335,7 @@ async def _modular_update( if first_update and module.__class__ in self.FIRST_UPDATE_MODULES: module._last_update_time = update_time continue - if ( - not module.update_interval - or not module._last_update_time - or (update_time - module._last_update_time) >= module.update_interval - ): + if module._should_update(update_time): module_queries.append(module) req.update(query) @@ -304,7 +378,7 @@ async def _handle_modular_update_error( """ msg_part = "on first update" if first_update else "after first update" - _LOGGER.error( + _LOGGER.debug( "Error querying %s for modules '%s' %s: %s", self.host, module_names, @@ -317,7 +391,7 @@ async def _handle_modular_update_error( resp = await self.protocol.query({meth: params}) responses[meth] = resp[meth] except Exception as iex: - _LOGGER.error( + _LOGGER.debug( "Error querying %s individually for module query '%s' %s: %s", self.host, meth, @@ -349,9 +423,8 @@ async def _initialize_modules(self) -> None: ) or mod.__name__ in child_modules_to_skip: continue required_component = cast(str, mod.REQUIRED_COMPONENT) - if required_component in self._components or ( - mod.REQUIRED_KEY_ON_PARENT - and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + if required_component in self._components or any( + self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS ): _LOGGER.debug( "Device %s, found required %s, adding %s to modules.", @@ -375,6 +448,11 @@ async def _initialize_modules(self) -> None: ): self._modules[Thermostat.__name__] = Thermostat(self, "thermostat") + # We move time to the beginning so other modules can access the + # time and timezone after update if required. e.g. cleanrecords + if Time.__name__ in self._modules: + self._modules.move_to_end(Time.__name__, last=False) + async def _initialize_features(self) -> None: """Initialize device features.""" self._add_feature( @@ -440,19 +518,6 @@ async def _initialize_features(self) -> None: ) ) - if "overheated" in self._info: - self._add_feature( - Feature( - self, - id="overheated", - name="Overheated", - attribute_getter=lambda x: x._info["overheated"], - icon="mdi:heat-wave", - type=Feature.Type.BinarySensor, - category=Feature.Category.Info, - ) - ) - # We check for the key available, and not for the property truthiness, # as the value is falsy when the device is off. if "on_time" in self._info: @@ -480,12 +545,25 @@ async def _initialize_features(self) -> None: ) ) + if self.parent is not None and ( + cs := self.parent.modules.get(Module.ChildSetup) + ): + self._add_feature( + Feature( + device=self, + id="unpair", + name="Unpair device", + container=cs, + attribute_setter=lambda: cs.unpair(self.device_id), + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + for module in self.modules.values(): module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) - for child in self._children.values(): - await child._initialize_features() @property def _is_hub_child(self) -> bool: @@ -507,18 +585,13 @@ def sys_info(self) -> dict[str, Any]: @property def model(self) -> str: """Returns the device model.""" - return str(self._info.get("model")) + # If update hasn't been called self._device_info can't be used + if self._last_update: + return self.device_info.short_name - @property - def _model_region(self) -> str: - """Return device full model name and region.""" - if (disco := self._discovery_info) and ( - disco_model := disco.get("device_model") - ): - return disco_model - # Some devices have the region in the specs element. - region = f"({specs})" if (specs := self._info.get("specs")) else "" - return f"{self.model}{region}" + disco_model = str(self._info.get("device_model")) + long_name, _, _ = disco_model.partition("(") + return long_name @property def alias(self) -> str | None: @@ -618,12 +691,8 @@ def _update_internal_state(self, info: dict[str, Any]) -> None: """ self._info = info - async def _query_helper( - self, method: str, params: dict | None = None, child_ids: None = None - ) -> dict: - res = await self.protocol.query({method: params}) - - return res + async def _query_helper(self, method: str, params: dict | None = None) -> dict: + return await self.protocol.query({method: params}) @property def ssid(self) -> str: @@ -700,6 +769,9 @@ async def wifi_join( if not self.credentials: raise AuthenticationError("Device requires authentication.") + if not keytype: + raise KasaException("KeyType is required for this device.") + payload = { "account": { "username": base64.b64encode( @@ -772,10 +844,11 @@ def device_type(self) -> DeviceType: if self._device_type is not DeviceType.Unknown: return self._device_type - # Fallback to device_type (from disco info) - type_str = self._info.get("type", self._info.get("device_type")) - - if not type_str: # no update or discovery info + if ( + not (type_str := self._info.get("type", self._info.get("device_type"))) + or not self._components + ): + # no update or discovery info return self._device_type self._device_type = self._get_device_type_from_components( @@ -809,13 +882,17 @@ def _get_device_type_from_components( return DeviceType.Sensor if "ENERGY" in device_type: return DeviceType.Thermostat + if "ROBOVAC" in device_type: + return DeviceType.Vacuum + if "TAPOCHIME" in device_type: + return DeviceType.Chime _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None - ) -> _DeviceInfo: + ) -> DeviceInfo: """Get model information for a device.""" di = info["get_device_info"] components = [comp["id"] for comp in info["component_nego"]["component_list"]] @@ -839,12 +916,15 @@ def _get_device_info( components, device_family ) fw_version_full = di["fw_ver"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None _protocol, devicetype = device_family.split(".") # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. brand = devicetype[:4].lower() - return _DeviceInfo( + return DeviceInfo( short_name=short_name, long_name=long_name, brand=brand, diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index c56970438..91efa33dc 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode @@ -20,15 +21,16 @@ def allow_update_after( - func: Callable[Concatenate[_T, _P], Awaitable[dict]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]: + func: Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: """Define a wrapper to set _last_update_time to None. This will ensure that a module is updated in the next update cycle after a value has been changed. """ - async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: + @wraps(func) + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R: try: return await func(self, *args, **kwargs) finally: @@ -40,6 +42,7 @@ async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]: """Define a wrapper to raise an error if the last module update was an error.""" + @wraps(func) def _wrap(self: _T) -> _R: if err := self._last_update_error: raise err @@ -54,14 +57,16 @@ class SmartModule(Module): NAME: str #: Module is initialized, if the given component is available REQUIRED_COMPONENT: str | None = None - #: Module is initialized, if the given key available in the main sysinfo - REQUIRED_KEY_ON_PARENT: str | None = None + #: Module is initialized, if any of the given keys exists in the sysinfo + SYSINFO_LOOKUP_KEYS: list[str] = [] #: Query to execute during the main update cycle - QUERY_GETTER_NAME: str + QUERY_GETTER_NAME: str = "" REGISTERED_MODULES: dict[str, type[SmartModule]] = {} MINIMUM_UPDATE_INTERVAL_SECS = 0 + MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + UPDATE_INTERVAL_AFTER_ERROR_SECS = 30 DISABLE_AFTER_ERROR_COUNT = 10 @@ -72,6 +77,7 @@ def __init__(self, device: SmartDevice, module: str) -> None: self._last_update_time: float | None = None self._last_update_error: KasaException | None = None self._error_count = 0 + self._logged_remove_keys: list[str] = [] def __init_subclass__(cls, **kwargs) -> None: # We only want to register submodules in a modules package so that @@ -106,16 +112,27 @@ def _set_error(self, err: Exception | None) -> None: @property def update_interval(self) -> int: """Time to wait between updates.""" - if self._last_update_error is None: - return self.MINIMUM_UPDATE_INTERVAL_SECS + if self._last_update_error: + return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + + if self._device._is_hub_child: + return self.MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS - return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + return self.MINIMUM_UPDATE_INTERVAL_SECS @property def disabled(self) -> bool: """Return true if the module is disabled due to errors.""" return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + def _should_update(self, update_time: float) -> bool: + """Return true if module should update based on delay parameters.""" + return ( + not self.update_interval + or not self._last_update_time + or (update_time - self._last_update_time) >= self.update_interval + ) + @classmethod def _module_name(cls) -> str: return getattr(cls, "NAME", cls.__name__) @@ -138,7 +155,9 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ - return {self.QUERY_GETTER_NAME: None} + if self.QUERY_GETTER_NAME: + return {self.QUERY_GETTER_NAME: None} + return {} async def call(self, method: str, params: dict | None = None) -> dict: """Call a method. @@ -147,6 +166,15 @@ async def call(self, method: str, params: dict | None = None) -> dict: """ return await self._device._query_helper(method, params) + @property + def optional_response_keys(self) -> list[str]: + """Return optional response keys for the module. + + Defaults to no keys. Overriding this and providing keys will remove + instead of raise on error. + """ + return [] + @property def data(self) -> dict[str, Any]: """Return response data for the module. @@ -179,12 +207,31 @@ def data(self) -> dict[str, Any]: filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + remove_keys: list[str] = [] for data_item in filtered_data: if isinstance(filtered_data[data_item], SmartErrorCode): - raise DeviceError( - f"{data_item} for {self.name}", error_code=filtered_data[data_item] + if data_item in self.optional_response_keys: + remove_keys.append(data_item) + else: + raise DeviceError( + f"{data_item} for {self.name}", + error_code=filtered_data[data_item], + ) + + for key in remove_keys: + if key not in self._logged_remove_keys: + self._logged_remove_keys.append(key) + _LOGGER.debug( + "Removed key %s from response for device %s as it returned " + "error: %s. This message will only be logged once per key.", + key, + self._device.host, + filtered_data[key], ) - if len(filtered_data) == 1: + + filtered_data.pop(key) + + if len(filtered_data) == 1 and not remove_keys: return next(iter(filtered_data.values())) return filtered_data diff --git a/kasa/smartcam/__init__.py b/kasa/smartcam/__init__.py index 574459f46..b16e5410b 100644 --- a/kasa/smartcam/__init__.py +++ b/kasa/smartcam/__init__.py @@ -1,5 +1,7 @@ """Package for supporting tapo-branded cameras.""" +from .detectionmodule import DetectionModule +from .smartcamchild import SmartCamChild from .smartcamdevice import SmartCamDevice -__all__ = ["SmartCamDevice"] +__all__ = ["SmartCamDevice", "SmartCamChild", "DetectionModule"] diff --git a/kasa/smartcam/detectionmodule.py b/kasa/smartcam/detectionmodule.py new file mode 100644 index 000000000..9a147f99b --- /dev/null +++ b/kasa/smartcam/detectionmodule.py @@ -0,0 +1,58 @@ +"""SmartCamModule base class for all detections.""" + +from __future__ import annotations + +import logging + +from kasa.feature import Feature +from kasa.smart.smartmodule import allow_update_after +from kasa.smartcam.smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class DetectionModule(SmartCamModule): + """SmartCamModule base class for all detections.""" + + #: Feature ID, filled by inheriting class + DETECTION_FEATURE_ID: str = "" + + #: User-friendly short description, filled by inheriting class + DETECTION_FEATURE_NAME: str = "" + + #: Feature setter method name, filled by inheriting class + QUERY_SETTER_NAME: str = "" + + #: Feature section name, filled by inheriting class + QUERY_SET_SECTION_NAME: str = "" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id=self.DETECTION_FEATURE_ID, + name=self.DETECTION_FEATURE_NAME, + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + def enabled(self) -> bool: + """Return the detection enabled state.""" + return self.data[self.QUERY_SECTION_NAMES]["enabled"] == "on" + + @allow_update_after + async def set_enabled(self, enable: bool) -> dict: + """Set the detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + self.QUERY_SETTER_NAME, + self.QUERY_MODULE_NAME, + self.QUERY_SET_SECTION_NAME, + params, + ) diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 16d595811..3434bddbf 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -1,19 +1,49 @@ """Modules for SMARTCAM devices.""" from .alarm import Alarm +from .babycrydetection import BabyCryDetection +from .barkdetection import BarkDetection +from .battery import Battery from .camera import Camera from .childdevice import ChildDevice +from .childsetup import ChildSetup from .device import DeviceModule +from .glassdetection import GlassDetection +from .homekit import HomeKit from .led import Led +from .lensmask import LensMask +from .linecrossingdetection import LineCrossingDetection +from .matter import Matter +from .meowdetection import MeowDetection +from .motiondetection import MotionDetection from .pantilt import PanTilt +from .persondetection import PersonDetection +from .petdetection import PetDetection +from .tamperdetection import TamperDetection from .time import Time +from .vehicledetection import VehicleDetection __all__ = [ "Alarm", + "BabyCryDetection", + "BarkDetection", + "Battery", "Camera", "ChildDevice", + "ChildSetup", "DeviceModule", + "GlassDetection", "Led", + "LineCrossingDetection", + "MeowDetection", "PanTilt", + "PersonDetection", + "PetDetection", "Time", + "HomeKit", + "Matter", + "MotionDetection", + "LensMask", + "TamperDetection", + "VehicleDetection", ] diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py index 12d434645..df1891ecf 100644 --- a/kasa/smartcam/modules/alarm.py +++ b/kasa/smartcam/modules/alarm.py @@ -2,7 +2,12 @@ from __future__ import annotations +from typing import Annotated + from ...feature import Feature +from ...interfaces import Alarm as AlarmInterface +from ...module import FeatureAttribute +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule DURATION_MIN = 0 @@ -12,12 +17,9 @@ VOLUME_MAX = 10 -class Alarm(SmartCamModule): +class Alarm(SmartCamModule, AlarmInterface): """Implementation of alarm module.""" - # Needs a different name to avoid clashing with SmartAlarm - NAME = "SmartCamAlarm" - REQUIRED_COMPONENT = "siren" QUERY_GETTER_NAME = "getSirenStatus" QUERY_MODULE_NAME = "siren" @@ -106,20 +108,18 @@ def _initialize_features(self) -> None: ) @property - def alarm_sound(self) -> str: + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: """Return current alarm sound.""" return self.data["getSirenConfig"]["siren_type"] - async def set_alarm_sound(self, sound: str) -> dict: + @allow_update_after + async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]: """Set alarm sound. See *alarm_sounds* for list of available sounds. """ - if sound not in self.alarm_sounds: - raise ValueError( - f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" - ) - return await self.call("setSirenConfig", {"siren": {"siren_type": sound}}) + config = self._validate_and_get_config(sound=sound) + return await self.call("setSirenConfig", {"siren": config}) @property def alarm_sounds(self) -> list[str]: @@ -127,40 +127,90 @@ def alarm_sounds(self) -> list[str]: return self.data["getSirenTypeList"]["siren_type_list"] @property - def alarm_volume(self) -> int: + def alarm_volume(self) -> Annotated[int, FeatureAttribute()]: """Return alarm volume. Unlike duration the device expects/returns a string for volume. """ return int(self.data["getSirenConfig"]["volume"]) - async def set_alarm_volume(self, volume: int) -> dict: + @allow_update_after + async def set_alarm_volume( + self, volume: int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" - if volume < VOLUME_MIN or volume > VOLUME_MAX: - raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") - return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}}) + config = self._validate_and_get_config(volume=volume) + return await self.call("setSirenConfig", {"siren": config}) @property - def alarm_duration(self) -> int: + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: """Return alarm duration.""" return self.data["getSirenConfig"]["duration"] - async def set_alarm_duration(self, duration: int) -> dict: + @allow_update_after + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: """Set alarm volume.""" - if duration < DURATION_MIN or duration > DURATION_MAX: - msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" - raise ValueError(msg) - return await self.call("setSirenConfig", {"siren": {"duration": duration}}) + config = self._validate_and_get_config(duration=duration) + return await self.call("setSirenConfig", {"siren": config}) @property def active(self) -> bool: """Return true if alarm is active.""" return self.data["getSirenStatus"]["status"] != "off" - async def play(self) -> dict: - """Play alarm.""" + async def play( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + """Play alarm. + + The optional *duration*, *volume*, and *sound* to override the device settings. + *duration* is in seconds. + See *alarm_sounds* for the list of sounds available for the device. + """ + if config := self._validate_and_get_config( + duration=duration, volume=volume, sound=sound + ): + await self.call("setSirenConfig", {"siren": config}) + return await self.call("setSirenStatus", {"siren": {"status": "on"}}) async def stop(self) -> dict: """Stop alarm.""" return await self.call("setSirenStatus", {"siren": {"status": "off"}}) + + def _validate_and_get_config( + self, + *, + duration: int | None = None, + volume: int | None = None, + sound: str | None = None, + ) -> dict: + if sound and sound not in self.alarm_sounds: + raise ValueError( + f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" + ) + + if duration is not None and ( + duration < DURATION_MIN or duration > DURATION_MAX + ): + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + raise ValueError(msg) + + if volume is not None and (volume < VOLUME_MIN or volume > VOLUME_MAX): + raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") + + config: dict[str, str | int] = {} + if sound: + config["siren_type"] = sound + if duration is not None: + config["duration"] = duration + if volume is not None: + config["volume"] = str(volume) + + return config diff --git a/kasa/smartcam/modules/babycrydetection.py b/kasa/smartcam/modules/babycrydetection.py new file mode 100644 index 000000000..6289b5177 --- /dev/null +++ b/kasa/smartcam/modules/babycrydetection.py @@ -0,0 +1,24 @@ +"""Implementation of baby cry detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class BabyCryDetection(DetectionModule): + """Implementation of baby cry detection module.""" + + REQUIRED_COMPONENT = "babyCryDetection" + + QUERY_GETTER_NAME = "getBCDConfig" + QUERY_MODULE_NAME = "sound_detection" + QUERY_SECTION_NAMES = "bcd" + + DETECTION_FEATURE_ID = "baby_cry_detection" + DETECTION_FEATURE_NAME = "Baby cry detection" + QUERY_SETTER_NAME = "setBCDConfig" + QUERY_SET_SECTION_NAME = "bcd" diff --git a/kasa/smartcam/modules/barkdetection.py b/kasa/smartcam/modules/barkdetection.py new file mode 100644 index 000000000..8c3b3b823 --- /dev/null +++ b/kasa/smartcam/modules/barkdetection.py @@ -0,0 +1,24 @@ +"""Implementation of bark detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class BarkDetection(DetectionModule): + """Implementation of bark detection module.""" + + REQUIRED_COMPONENT = "barkDetection" + + QUERY_GETTER_NAME = "getBarkDetectionConfig" + QUERY_MODULE_NAME = "bark_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "bark_detection" + DETECTION_FEATURE_NAME = "Bark detection" + QUERY_SETTER_NAME = "setBarkDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/battery.py b/kasa/smartcam/modules/battery.py new file mode 100644 index 000000000..b1e25f256 --- /dev/null +++ b/kasa/smartcam/modules/battery.py @@ -0,0 +1,137 @@ +"""Implementation of smartcam battery module.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class Battery(SmartCamModule): + """Implementation of a battery module.""" + + REQUIRED_COMPONENT = "battery" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + "battery_low", + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery_percent", + icon="mdi:battery", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + # Optional on some battery cameras (e.g., C460). + if self._optional_float_sysinfo("battery_temperature") is not None: + self._add_feature( + Feature( + self._device, + "battery_temperature", + "Battery temperature", + container=self, + attribute_getter="battery_temperature", + icon="mdi:battery", + unit_getter=lambda: "celsius", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + if self._optional_float_sysinfo("battery_voltage") is not None: + self._add_feature( + Feature( + self._device, + "battery_voltage", + "Battery voltage", + container=self, + attribute_getter="battery_voltage", + icon="mdi:battery", + unit_getter=lambda: "V", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + "battery_charging", + "Battery charging", + container=self, + attribute_getter="battery_charging", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) + ) + + def _optional_float_sysinfo(self, key: str) -> float | None: + """Return sys_info[key] as float, or None if not available or invalid.""" + v_any: Any = self._device.sys_info.get(key) + if v_any in (None, "NO"): + return None + + try: + # Accept ints/floats and numeric strings. + return float(v_any) + except (TypeError, ValueError): + return None + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def battery_percent(self) -> int: + """Return battery level.""" + return self._device.sys_info["battery_percent"] + + @property + def battery_low(self) -> bool: + """Return True if battery is low.""" + return self._device.sys_info["low_battery"] + + @property + def battery_temperature(self) -> float | None: + """Return battery temperature in °C (if available).""" + return self._optional_float_sysinfo("battery_temperature") + + @property + def battery_voltage(self) -> float | None: + """Return battery voltage in V (if available).""" + v = self._optional_float_sysinfo("battery_voltage") + return None if v is None else v / 1_000 + + @property + def battery_charging(self) -> bool: + """Return True if battery is charging.""" + v = self._device.sys_info.get("battery_charging") + if isinstance(v, bool): + return v + if v is None: + return False + return str(v).strip().lower() in ("yes", "true", "1", "charging", "on") diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py index 815db62bb..bd4b28086 100644 --- a/kasa/smartcam/modules/camera.py +++ b/kasa/smartcam/modules/camera.py @@ -1,47 +1,69 @@ -"""Implementation of device module.""" +"""Implementation of camera module.""" from __future__ import annotations import base64 import logging +from enum import StrEnum +from typing import Annotated from urllib.parse import quote_plus from ...credentials import Credentials -from ...device_type import DeviceType from ...feature import Feature from ...json import loads as json_loads +from ...module import FeatureAttribute, Module from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) LOCAL_STREAMING_PORT = 554 +ONVIF_PORT = 2020 + + +class StreamResolution(StrEnum): + """Class for stream resolution.""" + + HD = "HD" + SD = "SD" class Camera(SmartCamModule): """Implementation of device module.""" - QUERY_GETTER_NAME = "getLensMaskConfig" - QUERY_MODULE_NAME = "lens_mask" - QUERY_SECTION_NAMES = "lens_mask_info" + REQUIRED_COMPONENT = "video" def _initialize_features(self) -> None: """Initialize features after the initial update.""" - self._add_feature( - Feature( - self._device, - id="state", - name="State", - attribute_getter="is_on", - attribute_setter="set_state", - type=Feature.Type.Switch, - category=Feature.Category.Primary, + if Module.LensMask in self._device.modules: + self._add_feature( + Feature( + self._device, + id="state", + name="State", + container=self, + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) ) - ) @property def is_on(self) -> bool: - """Return the device id.""" - return self.data["lens_mask_info"]["enabled"] == "off" + """Return the device on state.""" + if lens_mask := self._device.modules.get(Module.LensMask): + return not lens_mask.enabled + return True + + async def set_state(self, on: bool) -> Annotated[dict, FeatureAttribute()]: + """Set the device on state. + + If the device does not support setting state will do nothing. + """ + if lens_mask := self._device.modules.get(Module.LensMask): + # Turning off enables the privacy mask which is why value is reversed. + return await lens_mask.set_enabled(not on) + return {} def _get_credentials(self) -> Credentials | None: """Get credentials from .""" @@ -64,7 +86,12 @@ def _get_credentials(self) -> Credentials | None: return None - def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: + def stream_rtsp_url( + self, + credentials: Credentials | None = None, + *, + stream_resolution: StreamResolution = StreamResolution.HD, + ) -> str | None: """Return the local rtsp streaming url. :param credentials: Credentials for camera account. @@ -73,26 +100,30 @@ def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: :return: rtsp url with escaped credentials or None if no credentials or camera is off. """ - if not self.is_on: + if self._device._is_hub_child: return None - dev = self._device + + streams = { + StreamResolution.HD: "stream1", + StreamResolution.SD: "stream2", + } + if (stream := streams.get(stream_resolution)) is None: + return None + if not credentials: credentials = self._get_credentials() if not credentials or not credentials.username or not credentials.password: return None + username = quote_plus(credentials.username) password = quote_plus(credentials.password) - return f"rtsp://{username}:{password}@{dev.host}:{LOCAL_STREAMING_PORT}/stream1" - - async def set_state(self, on: bool) -> dict: - """Set the device state.""" - # Turning off enables the privacy mask which is why value is reversed. - params = {"enabled": "off" if on else "on"} - return await self._device._query_setter_helper( - "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params - ) - - async def _check_supported(self) -> bool: - """Additional check to see if the module is supported by the device.""" - return self._device.device_type is DeviceType.Camera + + return f"rtsp://{username}:{password}@{self._device.host}:{LOCAL_STREAMING_PORT}/{stream}" + + def onvif_url(self) -> str | None: + """Return the onvif url.""" + if self._device._is_hub_child: + return None + + return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" diff --git a/kasa/smartcam/modules/childdevice.py b/kasa/smartcam/modules/childdevice.py index c4de58385..812fd0c1b 100644 --- a/kasa/smartcam/modules/childdevice.py +++ b/kasa/smartcam/modules/childdevice.py @@ -19,7 +19,10 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ - return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}} + q = {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}} + if self._device.device_type is DeviceType.Hub: + q["getChildDeviceComponentList"] = {"childControl": {"start_index": 0}} + return q async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py new file mode 100644 index 000000000..676bd6368 --- /dev/null +++ b/kasa/smartcam/modules/childsetup.py @@ -0,0 +1,112 @@ +"""Implementation for child device setup. + +This module allows pairing and disconnecting child devices. +""" + +from __future__ import annotations + +import asyncio +import logging + +from ...feature import Feature +from ...interfaces.childsetup import ChildSetup as ChildSetupInterface +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class ChildSetup(SmartCamModule, ChildSetupInterface): + """Implementation for child device setup.""" + + REQUIRED_COMPONENT = "childQuickSetup" + QUERY_GETTER_NAME = "getSupportChildDeviceCategory" + QUERY_MODULE_NAME = "childControl" + _categories: list[str] = [] + + # Supported child device categories will hardly ever change + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="pair", + name="Pair", + container=self, + attribute_setter="pair", + category=Feature.Category.Config, + type=Feature.Type.Action, + ) + ) + + async def _post_update_hook(self) -> None: + self._categories = [ + cat["category"].replace("ipcamera", "camera") + for cat in self.data["device_category_list"] + ] + + @property + def supported_categories(self) -> list[str]: + """Supported child device categories.""" + return self._categories + + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair them.""" + await self.call( + "startScanChildDevice", {"childControl": {"category": self._categories}} + ) + + _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) + + await asyncio.sleep(timeout) + res = await self.call( + "getScanChildDeviceList", {"childControl": {"category": self._categories}} + ) + + detected_list = res["getScanChildDeviceList"]["child_device_list"] + if not detected_list: + _LOGGER.warning( + "No devices found, make sure to activate pairing " + "mode on the devices to be added." + ) + return [] + + _LOGGER.info( + "Discovery done, found %s devices: %s", + len(detected_list), + detected_list, + ) + return await self._add_devices(detected_list) + + async def _add_devices(self, detected_list: list[dict]) -> list[dict]: + """Add devices based on getScanChildDeviceList response.""" + await self.call( + "addScanChildDeviceList", + {"childControl": {"child_device_list": detected_list}}, + ) + + await self._device.update() + + successes = [] + for detected in detected_list: + device_id = detected["device_id"] + + result = "not added" + if device_id in self._device._children: + result = "added" + successes.append(detected) + + msg = f"{detected['device_model']} - {device_id} - {result}" + _LOGGER.info("Adding child to %s: %s", self._device.host, msg) + + return successes + + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" + _LOGGER.info("Going to unpair %s from %s", device_id, self) + + payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}} + res = await self.call("removeChildDeviceList", payload) + await self._device.update() + return res diff --git a/kasa/smartcam/modules/device.py b/kasa/smartcam/modules/device.py index 0541d75c6..7f84de1e5 100644 --- a/kasa/smartcam/modules/device.py +++ b/kasa/smartcam/modules/device.py @@ -14,6 +14,18 @@ class DeviceModule(SmartCamModule): QUERY_MODULE_NAME = "device_info" QUERY_SECTION_NAMES = ["basic_info", "info"] + def query(self) -> dict: + """Query to execute during the update cycle.""" + if self._device._is_hub_child: + # Child devices get their device info updated by the parent device. + # and generally don't support connection type as they're not + # connected to the network + return {} + q = super().query() + q["getConnectionType"] = {"network": {"get_connection_type": []}} + + return q + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( @@ -26,6 +38,32 @@ def _initialize_features(self) -> None: type=Feature.Type.Sensor, ) ) + if self.rssi is not None: + self._add_feature( + Feature( + self._device, + container=self, + id="rssi", + name="RSSI", + attribute_getter="rssi", + icon="mdi:signal", + unit_getter=lambda: "dBm", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + container=self, + id="signal_level", + name="Signal Level", + attribute_getter="signal_level", + icon="mdi:signal", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) async def _post_update_hook(self) -> None: """Overriden to prevent module disabling. @@ -37,4 +75,14 @@ async def _post_update_hook(self) -> None: @property def device_id(self) -> str: """Return the device id.""" - return self.data["basic_info"]["dev_id"] + return self._device._info["device_id"] + + @property + def rssi(self) -> int | None: + """Return the device id.""" + return self.data.get("getConnectionType", {}).get("rssiValue") + + @property + def signal_level(self) -> int | None: + """Return the device id.""" + return self.data.get("getConnectionType", {}).get("rssi") diff --git a/kasa/smartcam/modules/glassdetection.py b/kasa/smartcam/modules/glassdetection.py new file mode 100644 index 000000000..bd0c7ea90 --- /dev/null +++ b/kasa/smartcam/modules/glassdetection.py @@ -0,0 +1,24 @@ +"""Implementation of glass detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class GlassDetection(DetectionModule): + """Implementation of glass detection module.""" + + REQUIRED_COMPONENT = "glassDetection" + + QUERY_GETTER_NAME = "getGlassDetectionConfig" + QUERY_MODULE_NAME = "glass_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "glass_detection" + DETECTION_FEATURE_NAME = "Glass detection" + QUERY_SETTER_NAME = "setGlassDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/homekit.py b/kasa/smartcam/modules/homekit.py new file mode 100644 index 000000000..a35de4f96 --- /dev/null +++ b/kasa/smartcam/modules/homekit.py @@ -0,0 +1,16 @@ +"""Implementation of homekit module.""" + +from __future__ import annotations + +from ..smartcammodule import SmartCamModule + + +class HomeKit(SmartCamModule): + """Implementation of homekit module.""" + + REQUIRED_COMPONENT = "homekit" + + @property + def info(self) -> dict[str, str]: + """Not supported, return empty dict.""" + return {} diff --git a/kasa/smartcam/modules/led.py b/kasa/smartcam/modules/led.py index fb62c52dd..5b0912e7e 100644 --- a/kasa/smartcam/modules/led.py +++ b/kasa/smartcam/modules/led.py @@ -3,6 +3,7 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule @@ -19,6 +20,7 @@ def led(self) -> bool: """Return current led status.""" return self.data["config"]["enabled"] == "on" + @allow_update_after async def set_led(self, enable: bool) -> dict: """Set led. diff --git a/kasa/smartcam/modules/lensmask.py b/kasa/smartcam/modules/lensmask.py new file mode 100644 index 000000000..22ae0ab32 --- /dev/null +++ b/kasa/smartcam/modules/lensmask.py @@ -0,0 +1,33 @@ +"""Implementation of lens mask privacy module.""" + +from __future__ import annotations + +import logging + +from ...smart.smartmodule import allow_update_after +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class LensMask(SmartCamModule): + """Implementation of lens mask module.""" + + REQUIRED_COMPONENT = "lensMask" + + QUERY_GETTER_NAME = "getLensMaskConfig" + QUERY_MODULE_NAME = "lens_mask" + QUERY_SECTION_NAMES = "lens_mask_info" + + @property + def enabled(self) -> bool: + """Return the lens mask state.""" + return self.data["lens_mask_info"]["enabled"] == "on" + + @allow_update_after + async def set_enabled(self, enable: bool) -> dict: + """Set the lens mask state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params + ) diff --git a/kasa/smartcam/modules/linecrossingdetection.py b/kasa/smartcam/modules/linecrossingdetection.py new file mode 100644 index 000000000..2fc0de146 --- /dev/null +++ b/kasa/smartcam/modules/linecrossingdetection.py @@ -0,0 +1,24 @@ +"""Implementation of line crossing detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class LineCrossingDetection(DetectionModule): + """Implementation of line crossing detection module.""" + + REQUIRED_COMPONENT = "linecrossingDetection" + + QUERY_GETTER_NAME = "getLinecrossingDetectionConfig" + QUERY_MODULE_NAME = "linecrossing_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "line_crossing_detection" + DETECTION_FEATURE_NAME = "Line crossing detection" + QUERY_SETTER_NAME = "setLinecrossingDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/matter.py b/kasa/smartcam/modules/matter.py new file mode 100644 index 000000000..8ea0e4cf8 --- /dev/null +++ b/kasa/smartcam/modules/matter.py @@ -0,0 +1,44 @@ +"""Implementation of matter module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + + +class Matter(SmartCamModule): + """Implementation of matter module.""" + + QUERY_GETTER_NAME = "getMatterSetupInfo" + QUERY_MODULE_NAME = "matter" + REQUIRED_COMPONENT = "matter" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="matter_setup_code", + name="Matter setup code", + container=self, + attribute_getter=lambda x: x.info["setup_code"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + self._device, + id="matter_setup_payload", + name="Matter setup payload", + container=self, + attribute_getter=lambda x: x.info["setup_payload"], + type=Feature.Type.Sensor, + category=Feature.Category.Debug, + ) + ) + + @property + def info(self) -> dict[str, str]: + """Matter setup info.""" + return self.data diff --git a/kasa/smartcam/modules/meowdetection.py b/kasa/smartcam/modules/meowdetection.py new file mode 100644 index 000000000..6c02bfbae --- /dev/null +++ b/kasa/smartcam/modules/meowdetection.py @@ -0,0 +1,24 @@ +"""Implementation of meow detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class MeowDetection(DetectionModule): + """Implementation of meow detection module.""" + + REQUIRED_COMPONENT = "meowDetection" + + QUERY_GETTER_NAME = "getMeowDetectionConfig" + QUERY_MODULE_NAME = "meow_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "meow_detection" + DETECTION_FEATURE_NAME = "Meow detection" + QUERY_SETTER_NAME = "setMeowDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/motiondetection.py b/kasa/smartcam/modules/motiondetection.py new file mode 100644 index 000000000..df9b1863a --- /dev/null +++ b/kasa/smartcam/modules/motiondetection.py @@ -0,0 +1,24 @@ +"""Implementation of motion detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class MotionDetection(DetectionModule): + """Implementation of motion detection module.""" + + REQUIRED_COMPONENT = "detection" + + QUERY_GETTER_NAME = "getDetectionConfig" + QUERY_MODULE_NAME = "motion_detection" + QUERY_SECTION_NAMES = "motion_det" + + DETECTION_FEATURE_ID = "motion_detection" + DETECTION_FEATURE_NAME = "Motion detection" + QUERY_SETTER_NAME = "setDetectionConfig" + QUERY_SET_SECTION_NAME = "motion_det" diff --git a/kasa/smartcam/modules/pantilt.py b/kasa/smartcam/modules/pantilt.py index fb647f6f1..52b2db0d7 100644 --- a/kasa/smartcam/modules/pantilt.py +++ b/kasa/smartcam/modules/pantilt.py @@ -1,4 +1,4 @@ -"""Implementation of time module.""" +"""Implementation of pan/tilt module.""" from __future__ import annotations @@ -10,9 +10,13 @@ class PanTilt(SmartCamModule): - """Implementation of device_local_time.""" + """Implementation of pan/tilt module for PTZ cameras.""" REQUIRED_COMPONENT = "ptz" + QUERY_GETTER_NAME = "getPresetConfig" + QUERY_MODULE_NAME = "preset" + QUERY_SECTION_NAMES = ["preset"] + _pan_step = DEFAULT_PAN_STEP _tilt_step = DEFAULT_TILT_STEP @@ -88,10 +92,52 @@ async def set_tilt_step(value: int) -> None: ) ) - def query(self) -> dict: - """Query to execute during the update cycle.""" + if self._presets: + self._add_feature( + Feature( + self._device, + "ptz_preset", + "PTZ Preset", + container=self, + attribute_getter="preset", + attribute_setter="set_preset", + choices_getter=lambda: list(self._presets.keys()), + type=Feature.Type.Choice, + ) + ) + + @property + def _presets(self) -> dict[str, str]: + """Return presets from device data.""" + if "preset" not in self.data: + return {} + preset_info = self.data["preset"] + return { + name: preset_id + for preset_id, name in zip( + preset_info.get("id", []), preset_info.get("name", []), strict=False + ) + } + + @property + def preset(self) -> str | None: + """Return first preset name as current value.""" + return next(iter(self._presets.keys()), None) + + async def set_preset(self, preset: str) -> dict: + """Set preset by name or ID.""" + preset_id = self._presets.get(preset) + if preset_id: + return await self.goto_preset(preset_id) + if preset in self._presets.values(): + return await self.goto_preset(preset) return {} + @property + def presets(self) -> dict[str, str]: + """Return available presets as dict of name -> id.""" + return self._presets + async def pan(self, pan: int) -> dict: """Pan horizontally.""" return await self.move(pan=pan, tilt=0) @@ -105,3 +151,25 @@ async def move(self, *, pan: int, tilt: int) -> dict: return await self._device._raw_query( {"do": {"motor": {"move": {"x_coord": str(pan), "y_coord": str(tilt)}}}} ) + + async def get_presets(self) -> dict: + """Get presets.""" + return await self._device._raw_query( + {"getPresetConfig": {"preset": {"name": ["preset"]}}} + ) + + async def goto_preset(self, preset_id: str) -> dict: + """Go to preset.""" + return await self._device._raw_query( + {"motorMoveToPreset": {"preset": {"goto_preset": {"id": preset_id}}}} + ) + + async def save_preset(self, name: str) -> dict: + """Save preset.""" + return await self._device._raw_query( + { + "addMotorPostion": { # Note: API has typo in method name + "preset": {"set_preset": {"name": name, "save_ptz": "1"}} + } + } + ) diff --git a/kasa/smartcam/modules/persondetection.py b/kasa/smartcam/modules/persondetection.py new file mode 100644 index 000000000..3b1213e88 --- /dev/null +++ b/kasa/smartcam/modules/persondetection.py @@ -0,0 +1,24 @@ +"""Implementation of person detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class PersonDetection(DetectionModule): + """Implementation of person detection module.""" + + REQUIRED_COMPONENT = "personDetection" + + QUERY_GETTER_NAME = "getPersonDetectionConfig" + QUERY_MODULE_NAME = "people_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "person_detection" + DETECTION_FEATURE_NAME = "Person detection" + QUERY_SETTER_NAME = "setPersonDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/petdetection.py b/kasa/smartcam/modules/petdetection.py new file mode 100644 index 000000000..58ff5cc4f --- /dev/null +++ b/kasa/smartcam/modules/petdetection.py @@ -0,0 +1,24 @@ +"""Implementation of pet detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class PetDetection(DetectionModule): + """Implementation of pet detection module.""" + + REQUIRED_COMPONENT = "petDetection" + + QUERY_GETTER_NAME = "getPetDetectionConfig" + QUERY_MODULE_NAME = "pet_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "pet_detection" + DETECTION_FEATURE_NAME = "Pet detection" + QUERY_SETTER_NAME = "setPetDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/modules/tamperdetection.py b/kasa/smartcam/modules/tamperdetection.py new file mode 100644 index 000000000..aa1cc4745 --- /dev/null +++ b/kasa/smartcam/modules/tamperdetection.py @@ -0,0 +1,24 @@ +"""Implementation of tamper detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class TamperDetection(DetectionModule): + """Implementation of tamper detection module.""" + + REQUIRED_COMPONENT = "tamperDetection" + + QUERY_GETTER_NAME = "getTamperDetectionConfig" + QUERY_MODULE_NAME = "tamper_detection" + QUERY_SECTION_NAMES = "tamper_det" + + DETECTION_FEATURE_ID = "tamper_detection" + DETECTION_FEATURE_NAME = "Tamper detection" + QUERY_SETTER_NAME = "setTamperDetectionConfig" + QUERY_SET_SECTION_NAME = "tamper_det" diff --git a/kasa/smartcam/modules/time.py b/kasa/smartcam/modules/time.py index 4e5cb8df2..8d850dca8 100644 --- a/kasa/smartcam/modules/time.py +++ b/kasa/smartcam/modules/time.py @@ -2,15 +2,20 @@ from __future__ import annotations -from datetime import UTC, datetime, tzinfo +import logging +from datetime import UTC, datetime, timedelta, tzinfo from typing import cast from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ...cachedzoneinfo import CachedZoneInfo +from ...exceptions import KasaException from ...feature import Feature from ...interfaces import Time as TimeInterface +from ...smart.smartmodule import allow_update_after from ..smartcammodule import SmartCamModule +_LOGGER = logging.getLogger(__name__) + class Time(SmartCamModule, TimeInterface): """Implementation of device_local_time.""" @@ -73,18 +78,37 @@ def time(self) -> datetime: """Return device's current datetime.""" return self._time + @allow_update_after async def set_time(self, dt: datetime) -> dict: - """Set device time.""" - if not dt.tzinfo: - timestamp = dt.replace(tzinfo=self.timezone).timestamp() - else: - timestamp = dt.timestamp() - - lt = datetime.fromtimestamp(timestamp).isoformat().replace("T", " ") - params = {"seconds_from_1970": int(timestamp), "local_time": lt} - # Doesn't seem to update the time, perhaps because timing_mode is ntp - res = await self.call("setTimezone", {"system": {"clock_status": params}}) - if (zinfo := dt.tzinfo) and isinstance(zinfo, ZoneInfo): - tz_params = {"zone_id": zinfo.key} - res = await self.call("setTimezone", {"system": {"basic": tz_params}}) - return res + """Set device timezone derived from the datetime.""" + _LOGGER.warning( + "SmartCam devices do not support setting clock time directly; " + "only timezone settings will be updated." + ) + timezone = dt.tzinfo or self.timezone + if not isinstance(timezone, ZoneInfo): + raise KasaException( + "SmartCam devices can only update timezone using zoneinfo " + "timezones; setting clock time is not supported." + ) + + utc_offset = cast(timedelta | None, timezone.utcoffset(dt)) + params: dict[str, str] = { + "timezone": self._format_utc_offset(utc_offset), + "zone_id": timezone.key, + } + + return await self.call("setTimezone", {"system": {"basic": params}}) + + @staticmethod + def _format_utc_offset(offset: timedelta | None) -> str: + """Format a timedelta offset as UTC+HH:MM/UTC-HH:MM.""" + if offset is None: + offset = timedelta(0) + + total_seconds = int(offset.total_seconds()) + sign = "+" if total_seconds >= 0 else "-" + total_seconds = abs(total_seconds) + hours, remainder = divmod(total_seconds, 3600) + minutes = remainder // 60 + return f"UTC{sign}{hours:02d}:{minutes:02d}" diff --git a/kasa/smartcam/modules/vehicledetection.py b/kasa/smartcam/modules/vehicledetection.py new file mode 100644 index 000000000..f5da9b0c7 --- /dev/null +++ b/kasa/smartcam/modules/vehicledetection.py @@ -0,0 +1,24 @@ +"""Implementation of vehicle detection module.""" + +from __future__ import annotations + +import logging + +from kasa.smartcam.detectionmodule import DetectionModule + +_LOGGER = logging.getLogger(__name__) + + +class VehicleDetection(DetectionModule): + """Implementation of vehicle detection module.""" + + REQUIRED_COMPONENT = "vehicleDetection" + + QUERY_GETTER_NAME = "getVehicleDetectionConfig" + QUERY_MODULE_NAME = "vehicle_detection" + QUERY_SECTION_NAMES = "detection" + + DETECTION_FEATURE_ID = "vehicle_detection" + DETECTION_FEATURE_NAME = "Vehicle detection" + QUERY_SETTER_NAME = "setVehicleDetectionConfig" + QUERY_SET_SECTION_NAME = "detection" diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py new file mode 100644 index 000000000..cb9d8e989 --- /dev/null +++ b/kasa/smartcam/smartcamchild.py @@ -0,0 +1,121 @@ +"""Child device implementation.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..device import DeviceInfo +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper +from ..protocols.smartprotocol import SmartProtocol +from ..smart.smartchilddevice import SmartChildDevice +from ..smart.smartdevice import ComponentsRaw, SmartDevice +from .smartcamdevice import SmartCamDevice + +_LOGGER = logging.getLogger(__name__) + +# SmartCamChild devices have a different info format from getChildDeviceInfo +# than when querying getDeviceInfo directly on the child. +# As _get_device_info is also called by dump_devtools and generate_supported +# this key will be expected by _get_device_info +CHILD_INFO_FROM_PARENT = "child_info_from_parent" + + +class SmartCamChild(SmartChildDevice, SmartCamDevice): + """Presentation of a child device. + + This wraps the protocol communications and sets internal data for the child. + """ + + CHILD_DEVICE_TYPE_MAP = { + "camera": DeviceType.Camera, + } + + def __init__( + self, + parent: SmartDevice, + info: dict, + component_info_raw: ComponentsRaw, + *, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, + ) -> None: + _protocol = protocol or _ChildCameraProtocolWrapper( + info["device_id"], parent.protocol + ) + super().__init__(parent, info, component_info_raw, protocol=_protocol) + self._child_info_from_parent: dict = {} + + @property + def device_info(self) -> DeviceInfo: + """Return device info. + + Child device does not have it info and components in _last_update so + this overrides the base implementation to call _get_device_info with + info and components combined as they would be in _last_update. + """ + return self._get_device_info( + { + CHILD_INFO_FROM_PARENT: self._child_info_from_parent, + }, + None, + ) + + @staticmethod + def _map_child_info_from_parent(device_info: dict) -> dict: + mappings = { + "device_model": "model", + "sw_ver": "fw_ver", + "hw_id": "hwId", + } + return {mappings.get(k, k): v for k, v in device_info.items()} + + def _update_internal_state(self, info: dict[str, Any]) -> None: + """Update the internal info state. + + This is used by the parent to push updates to its children. + """ + # smartcam children have info with different keys to their own + # getDeviceInfo queries + self._child_info_from_parent = info + + # self._info will have the values normalized across smart and smartcam + # devices + self._info = self._map_child_info_from_parent(info) + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + if self._device_type == DeviceType.Unknown and self._info: + self._device_type = self._get_device_type_from_sysinfo(self._info) + return self._device_type + + @staticmethod + def _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> DeviceInfo: + """Get model information for a device.""" + if not (cifp := info.get(CHILD_INFO_FROM_PARENT)): + return SmartCamDevice._get_device_info(info, discovery_info) + + model = cifp["device_model"] + device_type = SmartCamDevice._get_device_type_from_sysinfo(cifp) + fw_version_full = cifp["sw_ver"] + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None + return DeviceInfo( + short_name=model, + long_name=model, + brand="tapo", + device_family=cifp["device_type"], + device_type=device_type, + hardware_version=cifp["hw_ver"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=True, + region=cifp.get("region"), + ) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 0090117ed..7bc6184f3 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -2,14 +2,23 @@ from __future__ import annotations +import base64 import logging -from typing import Any +from typing import Any, cast -from ..device import _DeviceInfo +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +from ..device import DeviceInfo, WifiNetwork from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import AuthenticationError, DeviceError, KasaException from ..module import Module +from ..protocols import SmartProtocol from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper from ..smart import SmartChildDevice, SmartDevice +from ..smart.smartdevice import ComponentsRaw from .modules import ChildDevice, DeviceModule from .smartcammodule import SmartCamModule @@ -22,29 +31,53 @@ class SmartCamDevice(SmartDevice): # Modules that are called as part of the init procedure on first update FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice} + STATIC_PUBLIC_KEY_B64 = ( + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4D6i0oD/Ga5qb//RfSe8MrPVI" + "rMIGecCxkcGWGj9kxxk74qQNq8XUuXoy2PczQ30BpiRHrlkbtBEPeWLpq85tfubT" + "UjhBz1NPNvWrC88uaYVGvzNpgzZOqDC35961uPTuvdUa8vztcUQjEZy16WbmetRj" + "URFIiWJgFCmemyYVbQIDAQAB" + ) + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, + ) -> None: + super().__init__(host, config=config, protocol=protocol) + self._public_key: str | None = None + self._networks: list[WifiNetwork] = [] + @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" - if ( - sysinfo - and (device_type := sysinfo.get("device_type")) - and device_type.endswith("HUB") - ): + if not (device_type := sysinfo.get("device_type")): + return DeviceType.Unknown + + if device_type.endswith("HUB"): return DeviceType.Hub + + if "DOORBELL" in device_type: + return DeviceType.Doorbell + return DeviceType.Camera @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None - ) -> _DeviceInfo: + ) -> DeviceInfo: """Get model information for a device.""" basic_info = info["getDeviceInfo"]["device_info"]["basic_info"] short_name = basic_info["device_model"] long_name = discovery_info["device_model"] if discovery_info else short_name device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info) fw_version_full = basic_info["sw_version"] - firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) - return _DeviceInfo( + if " " in fw_version_full: + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + else: + firmware_version, firmware_build = fw_version_full, None + return DeviceInfo( short_name=basic_info["device_model"], long_name=long_name, brand="tapo", @@ -62,23 +95,38 @@ def _update_internal_info(self, info_resp: dict) -> None: info = self._try_get_response(info_resp, "getDeviceInfo") self._info = self._map_info(info["device_info"]) - def _update_children_info(self) -> None: - """Update the internal child device info from the parent info.""" + def _update_internal_state(self, info: dict[str, Any]) -> None: + """Update the internal info state. + + This is used by the parent to push updates to its children. + """ + self._info = self._map_info(info) + + async def _update_children_info(self) -> bool: + """Update the internal child device info from the parent info. + + Return true if children added or deleted. + """ + changed = False if child_info := self._try_get_response( self._last_update, "getChildDeviceList", {} ): + changed = await self._create_delete_children( + child_info, self._last_update["getChildDeviceComponentList"] + ) + for info in child_info["child_device_list"]: - child_id = info["device_id"] + child_id = info.get("device_id") if child_id not in self._children: - _LOGGER.debug( - "Skipping child update for %s, probably unsupported device", - child_id, - ) + # _create_delete_children has already logged a message continue + self._children[child_id]._update_internal_state(info) + return changed + async def _initialize_smart_child( - self, info: dict, child_components: dict + self, info: dict, child_components_raw: ComponentsRaw ) -> SmartDevice: """Initialize a smart child device attached to a smartcam device.""" child_id = info["device_id"] @@ -93,11 +141,30 @@ async def _initialize_smart_child( return await SmartChildDevice.create( parent=self, child_info=info, - child_components=child_components, + child_components_raw=child_components_raw, protocol=child_protocol, last_update=initial_response, ) + async def _initialize_smartcam_child( + self, info: dict, child_components_raw: ComponentsRaw + ) -> SmartDevice: + """Initialize a smart child device attached to a smartcam device.""" + child_id = info["device_id"] + child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) + + app_component_list = { + "app_component_list": child_components_raw["component_list"] + } + from .smartcamchild import SmartCamChild + + return await SmartCamChild.create( + parent=self, + child_info=info, + child_components_raw=app_component_list, + protocol=child_protocol, + ) + async def _initialize_children(self) -> None: """Initialize children for hubs.""" child_info_query = { @@ -107,34 +174,22 @@ async def _initialize_children(self) -> None: resp = await self.protocol.query(child_info_query) self.internal_state.update(resp) - smart_children_components = { - child["device_id"]: { - comp["id"]: int(comp["ver_code"]) for comp in component_list - } - for child in resp["getChildDeviceComponentList"]["child_component_list"] - if (component_list := child.get("component_list")) - # Child camera devices will have a different component schema so only - # extract smart values. - and (first_comp := next(iter(component_list), None)) - and isinstance(first_comp, dict) - and "id" in first_comp - and "ver_code" in first_comp - } - children = {} - for info in resp["getChildDeviceList"]["child_device_list"]: - if ( - (category := info.get("category")) - and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP - and (child_id := info.get("device_id")) - and (child_components := smart_children_components.get(child_id)) - ): - children[child_id] = await self._initialize_smart_child( - info, child_components - ) - else: - _LOGGER.debug("Child device type not supported: %s", info) + async def _try_create_child( + self, info: dict, child_components: dict + ) -> SmartDevice | None: + if not (category := info.get("category")): + return None + + # Smart + if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + return await self._initialize_smart_child(info, child_components) + # Smartcam + from .smartcamchild import SmartCamChild + + if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP: + return await self._initialize_smartcam_child(info, child_components) - self._children = children + return None async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" @@ -155,9 +210,6 @@ async def _initialize_features(self) -> None: for feat in module._module_features.values(): self._add_feature(feat) - for child in self._children.values(): - await child._initialize_features() - async def _query_setter_helper( self, method: str, module: str, section: str, params: dict | None = None ) -> dict: @@ -165,12 +217,12 @@ async def _query_setter_helper( return res - async def _query_getter_helper( - self, method: str, module: str, sections: str | list[str] - ) -> Any: - res = await self.protocol.query({method: {module: {"name": sections}}}) - - return res + @staticmethod + def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: + return { + str(comp["name"]): int(comp["version"]) + for comp in components_raw["app_component_list"] + } async def _negotiate(self) -> None: """Perform initialization. @@ -181,34 +233,32 @@ async def _negotiate(self) -> None: initial_query = { "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, "getAppComponentList": {"app_component": {"name": "app_component_list"}}, + "getConnectionType": {"network": {"get_connection_type": {}}}, } resp = await self.protocol.query(initial_query) self._last_update.update(resp) self._update_internal_info(resp) - self._components = { - comp["name"]: int(comp["version"]) - for comp in resp["getAppComponentList"]["app_component"][ - "app_component_list" - ] - } + self._components_raw = cast( + ComponentsRaw, resp["getAppComponentList"]["app_component"] + ) + self._components = self._parse_components(self._components_raw) if "childControl" in self._components and not self.children: await self._initialize_children() def _map_info(self, device_info: dict) -> dict: + """Map the basic keys to the keys used by SmartDevices.""" basic_info = device_info["basic_info"] - return { - "model": basic_info["device_model"], - "device_type": basic_info["device_type"], - "alias": basic_info["device_alias"], - "fw_ver": basic_info["sw_version"], - "hw_ver": basic_info["hw_version"], - "mac": basic_info["mac"], - "hwId": basic_info.get("hw_id"), - "oem_id": basic_info["oem_id"], - "device_id": basic_info["dev_id"], + mappings = { + "device_model": "model", + "device_alias": "alias", + "sw_version": "fw_ver", + "hw_version": "hw_ver", + "hw_id": "hwId", + "dev_id": "device_id", } + return {mappings.get(k, k): v for k, v in basic_info.items()} @property def is_on(self) -> bool: @@ -228,7 +278,7 @@ async def set_state(self, on: bool) -> dict: @property def device_type(self) -> DeviceType: """Return the device type.""" - if self._device_type == DeviceType.Unknown: + if self._device_type == DeviceType.Unknown and self._info: self._device_type = self._get_device_type_from_sysinfo(self._info) return self._device_type @@ -251,11 +301,92 @@ async def set_alias(self, alias: str) -> dict: def hw_info(self) -> dict: """Return hardware info for the device.""" return { - "sw_ver": self._info.get("hw_ver"), - "hw_ver": self._info.get("fw_ver"), + "sw_ver": self._info.get("fw_ver"), + "hw_ver": self._info.get("hw_ver"), "mac": self._info.get("mac"), "type": self._info.get("type"), "hwId": self._info.get("hwId"), "dev_name": self.alias, "oemId": self._info.get("oem_id"), } + + @property + def rssi(self) -> int | None: + """Return the device id.""" + return self.modules[SmartCamModule.SmartCamDeviceModule].rssi + + async def wifi_scan(self) -> list[WifiNetwork]: + """Scan for available wifi networks.""" + + def _net_for_scan_info(res: dict) -> WifiNetwork: + return WifiNetwork( + ssid=res["ssid"], + auth=res["auth"], + encryption=res["encryption"], + rssi=res["rssi"], + bssid=res["bssid"], + ) + + _LOGGER.debug("Querying networks") + + resp = await self._query_helper("scanApList", {"onboarding": {"scan": {}}}) + scan_data: dict = resp["scanApList"]["onboarding"]["scan"] + self._public_key = scan_data.get("publicKey", "") + self._networks = [_net_for_scan_info(net) for net in scan_data["ap_list"]] + return self._networks + + async def wifi_join( + self, ssid: str, password: str, keytype: str = "wpa2_psk" + ) -> dict: + """Join the given wifi network. + + This method returns nothing as the device tries to activate the new + settings immediately instead of responding to the request. + + If joining the network fails, the device will return to the previous state + after some delay. + """ + if not self.credentials: + raise AuthenticationError("Device requires authentication.") + + if not self._networks: + await self.wifi_scan() + net = next( + (n for n in self._networks if getattr(n, "ssid", None) == ssid), None + ) + if net is None: + raise DeviceError(f"Network with SSID '{ssid}' not found.") + + public_key_b64 = self._public_key or self.STATIC_PUBLIC_KEY_B64 + key_bytes = base64.b64decode(public_key_b64) + public_key = serialization.load_der_public_key(key_bytes) + if not isinstance(public_key, RSAPublicKey): + raise TypeError("Loaded public key is not an RSA public key") + encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15()) + encrypted_password = base64.b64encode(encrypted).decode() + + payload = { + "onboarding": { + "connect": { + "auth": net.auth, + "bssid": net.bssid, + "encryption": net.encryption, + "password": encrypted_password, + "rssi": net.rssi, + "ssid": net.ssid, + } + } + } + + # The device does not respond to the request but changes the settings + # immediately which causes us to timeout. + # Thus, We limit retries and suppress the raised exception as useless. + try: + return await self.protocol.query({"connectAp": payload}, retry_count=0) + except DeviceError: + raise # Re-raise on device-reported errors + except KasaException: + _LOGGER.debug( + "Received a kasa exception for wifi join, but this is expected" + ) + return {} diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index ca1a3b824..7402b8cb8 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Final from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..modulemapping import ModuleName @@ -20,9 +20,44 @@ class SmartCamModule(SmartModule): """Base class for SMARTCAM modules.""" SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm") + SmartCamMotionDetection: Final[ModuleName[modules.MotionDetection]] = ModuleName( + "MotionDetection" + ) + SmartCamPersonDetection: Final[ModuleName[modules.PersonDetection]] = ModuleName( + "PersonDetection" + ) + SmartCamPetDetection: Final[ModuleName[modules.PetDetection]] = ModuleName( + "PetDetection" + ) + SmartCamTamperDetection: Final[ModuleName[modules.TamperDetection]] = ModuleName( + "TamperDetection" + ) + SmartCamBabyCryDetection: Final[ModuleName[modules.BabyCryDetection]] = ModuleName( + "BabyCryDetection" + ) + + SmartCamLineCrossingDetection: Final[ModuleName[modules.LineCrossingDetection]] = ( + ModuleName("LineCrossingDetection") + ) + SmartCamBarkDetection: Final[ModuleName[modules.BarkDetection]] = ModuleName( + "BarkDetection" + ) + SmartCamGlassDetection: Final[ModuleName[modules.GlassDetection]] = ModuleName( + "GlassDetection" + ) + SmartCamMeowDetection: Final[ModuleName[modules.MeowDetection]] = ModuleName( + "MeowDetection" + ) + SmartCamVehicleDetection: Final[ModuleName[modules.VehicleDetection]] = ModuleName( + "VehicleDetection" + ) + + SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery") + + SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName( + "devicemodule" + ) - #: Query to execute during the main update cycle - QUERY_GETTER_NAME: str #: Module name to be queried QUERY_MODULE_NAME: str #: Section name or names to be queried @@ -37,6 +72,8 @@ def query(self) -> dict: Default implementation uses the raw query getter w/o parameters. """ + if not self.QUERY_GETTER_NAME: + return {} section_names = ( {"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {} ) @@ -47,21 +84,7 @@ async def call(self, method: str, params: dict | None = None) -> dict: Just a helper method. """ - if params: - module = next(iter(params)) - section = next(iter(params[module])) - else: - module = "system" - section = "null" - - if method[:3] == "get": - return await self._device._query_getter_helper(method, module, section) - - if TYPE_CHECKING: - params = cast(dict[str, dict[str, Any]], params) - return await self._device._query_setter_helper( - method, module, section, params[module][section] - ) + return await self._device._query_helper(method, params) @property def data(self) -> dict: @@ -86,7 +109,8 @@ def data(self) -> dict: f" for '{self._module}'" ) - return query_resp.get(self.QUERY_MODULE_NAME) + # Some calls return the data under the module, others not + return query_resp.get(self.QUERY_MODULE_NAME, query_resp) else: found = {key: val for key, val in dev._last_update.items() if key in q} for key in q: diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py index 8ccdae65d..192b4156a 100644 --- a/kasa/transports/__init__.py +++ b/kasa/transports/__init__.py @@ -3,14 +3,20 @@ from .aestransport import AesEncyptionSession, AesTransport from .basetransport import BaseTransport from .klaptransport import KlapTransport, KlapTransportV2 +from .linkietransport import LinkieTransportV2 +from .sslaestransport import SslAesTransport +from .ssltransport import SslTransport from .xortransport import XorEncryption, XorTransport __all__ = [ "AesTransport", "AesEncyptionSession", + "SslTransport", + "SslAesTransport", "BaseTransport", "KlapTransport", "KlapTransportV2", + "LinkieTransportV2", "XorTransport", "XorEncryption", ] diff --git a/kasa/transports/aestransport.py b/kasa/transports/aestransport.py index 3466ca98e..45b963fe8 100644 --- a/kasa/transports/aestransport.py +++ b/kasa/transports/aestransport.py @@ -120,6 +120,8 @@ def __init__( @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @property diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 8934b2cc8..8253e0aef 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -48,6 +48,7 @@ import hashlib import logging import secrets +import ssl import struct import time from asyncio import Future @@ -92,8 +93,21 @@ class KlapTransport(BaseTransport): """ DEFAULT_PORT: int = 80 + DEFAULT_HTTPS_PORT: int = 4433 + SESSION_COOKIE_NAME = "TP_SESSIONID" TIMEOUT_COOKIE_NAME = "TIMEOUT" + # Copy & paste from sslaestransport + CIPHERS = ":".join( + [ + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + _ssl_context: ssl.SSLContext | None = None def __init__( self, @@ -125,12 +139,20 @@ def __init__( self._session_cookie: dict[str, Any] | None = None _LOGGER.debug("Created KLAP transport for %s", self._host) - self._app_url = URL(f"http://{self._host}:{self._port}/app") + protocol = "https" if config.connection_type.https else "http" + self._app_url = URL(f"{protocol}://{self._host}:{self._port}/app") self._request_url = self._app_url / "request" @property def default_port(self) -> int: """Default port for the transport.""" + config = self._config + if port := config.connection_type.http_port: + return port + + if config.connection_type.https: + return self.DEFAULT_HTTPS_PORT + return self.DEFAULT_PORT @property @@ -152,7 +174,9 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: url = self._app_url / "handshake1" - response_status, response_data = await self._http_client.post(url, data=payload) + response_status, response_data = await self._http_client.post( + url, data=payload, ssl=await self._get_ssl_context() + ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( @@ -214,8 +238,8 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if default_credentials_seed_auth_hash == server_hash: _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s, " - "but an authentication with %s default credentials matched", + "Device response did not match our expected hash on ip %s," + "but an authentication with %s default credentials worked", self._host, key, ) @@ -235,13 +259,16 @@ async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: if blank_seed_auth_hash == server_hash: _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s, " - "but an authentication with blank credentials matched", + "Device response did not match our expected hash on ip %s, " + "but an authentication with blank credentials worked", self._host, ) return local_seed, remote_seed, self._blank_auth_hash # type: ignore - msg = f"Server response doesn't match our challenge on ip {self._host}" + msg = ( + f"Device response did not match our challenge on ip {self._host}, " + f"check that your e-mail and password (both case-sensitive) are correct. " + ) _LOGGER.debug(msg) raise AuthenticationError(msg) @@ -260,6 +287,7 @@ async def perform_handshake2( url, data=payload, cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), ) if _LOGGER.isEnabledFor(logging.DEBUG): @@ -334,6 +362,7 @@ async def send(self, request: str) -> Generator[Future, None, dict[str, str]]: params={"seq": seq}, data=payload, cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), ) msg = ( @@ -410,6 +439,23 @@ def generate_owner_hash(creds: Credentials) -> bytes: un = creds.username return md5(un.encode()) + # Copy & paste from sslaestransport. + def _create_ssl_context(self) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.set_ciphers(self.CIPHERS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + # Copy & paste from sslaestransport. + async def _get_ssl_context(self) -> ssl.SSLContext: + if not self._ssl_context: + loop = asyncio.get_running_loop() + self._ssl_context = await loop.run_in_executor( + None, self._create_ssl_context + ) + return self._ssl_context + class KlapTransportV2(KlapTransport): """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" diff --git a/kasa/transports/linkietransport.py b/kasa/transports/linkietransport.py new file mode 100644 index 000000000..b817373c3 --- /dev/null +++ b/kasa/transports/linkietransport.py @@ -0,0 +1,145 @@ +"""Implementation of the linkie kasa camera transport.""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +import ssl +from typing import TYPE_CHECKING, cast +from urllib.parse import quote + +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException, _RetryableError +from kasa.httpclient import HttpClient +from kasa.json import loads as json_loads +from kasa.transports.xortransport import XorEncryption + +from .basetransport import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +class LinkieTransportV2(BaseTransport): + """Implementation of the Linkie encryption protocol. + + Linkie is used as the endpoint for TP-Link's camera encryption + protocol, used by newer firmware versions. + """ + + DEFAULT_PORT: int = 10443 + CIPHERS = ":".join( + [ + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + + def __init__(self, *, config: DeviceConfig) -> None: + super().__init__(config=config) + self._http_client = HttpClient(config) + self._ssl_context: ssl.SSLContext | None = None + self._app_url = URL(f"https://{self._host}:{self._port}/data/LINKIE2.json") + + self._headers = { + "Authorization": f"Basic {self.credentials_hash}", + "Content-Type": "application/x-www-form-urlencoded", + } + + @property + def default_port(self) -> int: + """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"]) + creds_combined = f"{creds.username}:{creds.password}" + return base64.b64encode(creds_combined.encode()).decode() + + async def _execute_send(self, request: str) -> dict: + """Execute a query on the device and wait for the response.""" + _LOGGER.debug("%s >> %s", self._host, request) + + encrypted_cmd = XorEncryption.encrypt(request)[4:] + b64_cmd = base64.b64encode(encrypted_cmd).decode() + url_safe_cmd = quote(b64_cmd, safe="!~*'()") + + status_code, response = await self._http_client.post( + self._app_url, + headers=self._headers, + data=f"content={url_safe_cmd}".encode(), + ssl=await self._get_ssl_context(), + ) + + if TYPE_CHECKING: + response = cast(bytes, response) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to passthrough" + ) + + # Expected response + try: + json_payload: dict = json_loads( + XorEncryption.decrypt(base64.b64decode(response)) + ) + _LOGGER.debug("%s << %s", self._host, json_payload) + return json_payload + except Exception: # noqa: S110 + pass + + # Device returned error as json plaintext + to_raise: KasaException | None = None + try: + error_payload: dict = json_loads(response) + to_raise = KasaException(f"Device {self._host} send error: {error_payload}") + except Exception as ex: + raise KasaException("Unable to read response") from ex + raise to_raise + + async def close(self) -> None: + """Close the http client and reset internal state.""" + await self._http_client.close() + + async def reset(self) -> None: + """Reset the transport. + + NOOP for this transport. + """ + + async def send(self, request: str) -> dict: + """Send a message to the device and return a response.""" + try: + return await self._execute_send(request) + except Exception as ex: + await self.reset() + raise _RetryableError( + f"Unable to query the device {self._host}:{self._port}: {ex}" + ) from ex + + async def _get_ssl_context(self) -> ssl.SSLContext: + if not self._ssl_context: + loop = asyncio.get_running_loop() + self._ssl_context = await loop.run_in_executor( + None, self._create_ssl_context + ) + return self._ssl_context + + def _create_ssl_context(self) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.set_ciphers(self.CIPHERS) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index 2061d293a..525085b05 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -8,6 +8,7 @@ import logging import secrets import ssl +from contextlib import suppress from enum import Enum, auto from typing import TYPE_CHECKING, Any, cast @@ -72,6 +73,7 @@ class SslAesTransport(BaseTransport): } CIPHERS = ":".join( [ + "ECDHE-RSA-AES128-GCM-SHA256", "AES256-GCM-SHA384", "AES256-SHA256", "AES128-GCM-SHA256", @@ -93,8 +95,12 @@ def __init__( not self._credentials or self._credentials.username is None ) and not self._credentials_hash: self._credentials = Credentials() + if self._login_version == 3: + _default_credentials = DEFAULT_CREDENTIALS["TAPOCAMERA_LV3"] + else: + _default_credentials = DEFAULT_CREDENTIALS["TAPOCAMERA"] self._default_credentials: Credentials = get_default_credentials( - DEFAULT_CREDENTIALS["TAPOCAMERA"] + _default_credentials ) self._http_client: HttpClient = HttpClient(config) @@ -125,12 +131,15 @@ def __init__( self._password = ch["pwd"] self._username = ch["un"] self._local_nonce: str | None = None + self._send_secure = True _LOGGER.debug("Created AES transport for %s", self._host) @property def default_port(self) -> int: """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port return self.DEFAULT_PORT @staticmethod @@ -160,6 +169,25 @@ def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR return error_code + def _get_response_inner_error(self, resp_dict: Any) -> SmartErrorCode | None: + # Device blocked errors have 'data' element at the root level, other inner + # errors are inside 'result' + error_code_raw = resp_dict.get("data", {}).get("code") + + if error_code_raw is None: + error_code_raw = resp_dict.get("result", {}).get("data", {}).get("code") + + if error_code_raw is None: + return None + try: + error_code = SmartErrorCode.from_int(error_code_raw) + except ValueError: + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + return error_code + def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: error_code = self._get_response_error(resp_dict) if error_code is SmartErrorCode.SUCCESS: @@ -194,6 +222,10 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: else: url = self._app_url + _LOGGER.debug( + "Sending secure passthrough from %s", + self._host, + ) encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore passthrough_request = { "method": "securePassthrough", @@ -216,6 +248,31 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ssl=await self._get_ssl_context(), ) + if TYPE_CHECKING: + assert self._encryption_session is not None + + # Devices can respond with 500 if another session is created from + # the same host. Decryption may not succeed after that + if status_code == 500: + msg = ( + f"Device {self._host} replied with status 500 after handshake, " + f"response: " + ) + decrypted = None + if isinstance(resp_dict, dict) and ( + response := resp_dict.get("result", {}).get("response") + ): + with suppress(Exception): + decrypted = self._encryption_session.decrypt(response.encode()) + + if decrypted: + msg += decrypted + else: + msg += str(resp_dict) + + _LOGGER.debug(msg) + raise _RetryableError(msg) + if status_code != 200: raise KasaException( f"{self._host} responded with an unexpected " @@ -228,7 +285,6 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: if TYPE_CHECKING: resp_dict = cast(dict[str, Any], resp_dict) - assert self._encryption_session is not None if "result" in resp_dict and "response" in resp_dict["result"]: raw_response: str = resp_dict["result"]["response"] @@ -254,6 +310,34 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]: ) from ex return ret_val # type: ignore[return-value] + async def send_unencrypted(self, request: str) -> dict[str, Any]: + """Send encrypted message as passthrough.""" + url = cast(URL, self._token_url) + + _LOGGER.debug( + "Sending unencrypted to %s", + self._host, + ) + + status_code, resp_dict = await self._http_client.post( + url, + json=request, + headers=self._headers, + ssl=await self._get_ssl_context(), + ) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to unencrypted send" + ) + + self._handle_response_error_code(resp_dict, "Error sending message") + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + return resp_dict + @staticmethod def generate_confirm_hash( local_nonce: str, server_nonce: str, pwd_hash: str @@ -302,8 +386,50 @@ def generate_tag(request: str, local_nonce: str, pwd_hash: str, seq: int) -> str async def perform_handshake(self) -> None: """Perform the handshake.""" - local_nonce, server_nonce, pwd_hash = await self.perform_handshake1() - await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) + result = await self.perform_handshake1() + if result: + local_nonce, server_nonce, pwd_hash = result + await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) + + async def try_perform_less_secure_login(self, username: str, password: str) -> bool: + """Perform the md5 login.""" + _LOGGER.debug("Performing less secure login...") + + pwd_hash = _md5_hash(password.encode()) + body = { + "method": "login", + "params": { + "hashed": True, + "password": pwd_hash, + "username": username, + }, + } + + status_code, resp_dict = await self._http_client.post( + self._app_url, + json=body, + headers=self._headers, + ssl=await self._get_ssl_context(), + ) + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to login" + ) + resp_dict = cast(dict, resp_dict) + if resp_dict.get("error_code") == 0 and ( + stok := resp_dict.get("result", {}).get("stok") + ): + _LOGGER.debug( + "Succesfully logged in to %s with less secure passthrough", self._host + ) + self._send_secure = False + self._token_url = URL(f"{str(self._app_url)}/stok={stok}/ds") + self._pwd_hash = pwd_hash + return True + + _LOGGER.debug("Unable to log in to %s with less secure login", self._host) + return False async def perform_handshake2( self, local_nonce: str, server_nonce: str, pwd_hash: str @@ -355,13 +481,50 @@ async def perform_handshake2( self._state = TransportState.ESTABLISHED _LOGGER.debug("Handshake2 complete ...") - async def perform_handshake1(self) -> tuple[str, str, str]: + def _pwd_to_hash(self) -> str: + """Return the password to hash.""" + if self._credentials and self._credentials != Credentials(): + return self._credentials.password + + if self._username and self._password: + return self._password + + return self._default_credentials.password + + def _is_less_secure_login(self, resp_dict: dict[str, Any]) -> bool: + result = ( + self._get_response_error(resp_dict) is SmartErrorCode.SESSION_EXPIRED + and (data := resp_dict.get("result", {}).get("data", {})) + and (encrypt_type := data.get("encrypt_type")) + and (encrypt_type != ["3"]) + ) + if result: + _LOGGER.debug( + "Received encrypt_type %s for %s, trying less secure login", + encrypt_type, + self._host, + ) + return result + + async def perform_handshake1(self) -> tuple[str, str, str] | None: """Perform the handshake1.""" resp_dict = None if self._username: local_nonce = secrets.token_bytes(8).hex().upper() resp_dict = await self.try_send_handshake1(self._username, local_nonce) + if ( + resp_dict + and self._is_less_secure_login(resp_dict) + and self._get_response_inner_error(resp_dict) + is not SmartErrorCode.BAD_USERNAME + and await self.try_perform_less_secure_login( + cast(str, self._username), self._pwd_to_hash() + ) + ): + self._state = TransportState.ESTABLISHED + return None + # Try the default username. If it fails raise the original error_code if ( not resp_dict @@ -369,27 +532,54 @@ async def perform_handshake1(self) -> tuple[str, str, str]: is not SmartErrorCode.INVALID_NONCE or "nonce" not in resp_dict["result"].get("data", {}) ): + _LOGGER.debug("Trying default credentials to %s", self._host) local_nonce = secrets.token_bytes(8).hex().upper() default_resp_dict = await self.try_send_handshake1( self._default_credentials.username, local_nonce ) + # INVALID_NONCE means device should perform secure login if ( default_error_code := self._get_response_error(default_resp_dict) ) is SmartErrorCode.INVALID_NONCE and "nonce" in default_resp_dict[ "result" ].get("data", {}): - _LOGGER.debug("Connected to {self._host} with default username") + _LOGGER.debug("Connected to %s with default username", self._host) self._username = self._default_credentials.username error_code = default_error_code resp_dict = default_resp_dict + # Otherwise could be less secure login + elif self._is_less_secure_login( + default_resp_dict + ) and await self.try_perform_less_secure_login( + self._default_credentials.username, self._pwd_to_hash() + ): + self._username = self._default_credentials.username + self._state = TransportState.ESTABLISHED + return None + # If the default login worked it's ok not to provide credentials but if + # it didn't raise auth error here. if not self._username: raise AuthenticationError( f"Credentials must be supplied to connect to {self._host}" ) + + # Device responds with INVALID_NONCE and a "nonce" to indicate ready + # for secure login. Otherwise error. if error_code is not SmartErrorCode.INVALID_NONCE or ( - resp_dict and "nonce" not in resp_dict["result"].get("data", {}) + resp_dict and "nonce" not in resp_dict.get("result", {}).get("data", {}) ): + if ( + resp_dict + and self._get_response_inner_error(resp_dict) + is SmartErrorCode.DEVICE_BLOCKED + ): + sec_left = resp_dict.get("data", {}).get("sec_left") + msg = "Device blocked" + ( + f" for {sec_left} seconds" if sec_left else "" + ) + raise DeviceError(msg, error_code=SmartErrorCode.DEVICE_BLOCKED) + raise AuthenticationError(f"Error trying handshake1: {resp_dict}") if TYPE_CHECKING: @@ -397,12 +587,8 @@ async def perform_handshake1(self) -> tuple[str, str, str]: server_nonce = resp_dict["result"]["data"]["nonce"] device_confirm = resp_dict["result"]["data"]["device_confirm"] - if self._credentials and self._credentials != Credentials(): - pwd_hash = _sha256_hash(self._credentials.password.encode()) - elif self._username and self._password: - pwd_hash = _sha256_hash(self._password.encode()) - else: - pwd_hash = _sha256_hash(self._default_credentials.password.encode()) + + pwd_hash = _sha256_hash(self._pwd_to_hash().encode()) expected_confirm_sha256 = self.generate_confirm_hash( local_nonce, server_nonce, pwd_hash @@ -414,7 +600,9 @@ async def perform_handshake1(self) -> tuple[str, str, str]: if TYPE_CHECKING: assert self._credentials assert self._credentials.password - pwd_hash = _md5_hash(self._credentials.password.encode()) + + pwd_hash = _md5_hash(self._pwd_to_hash().encode()) + expected_confirm_md5 = self.generate_confirm_hash( local_nonce, server_nonce, pwd_hash ) @@ -422,13 +610,17 @@ async def perform_handshake1(self) -> tuple[str, str, str]: _LOGGER.debug("Credentials match") return local_nonce, server_nonce, pwd_hash - msg = f"Server response doesn't match our challenge on ip {self._host}" + msg = ( + f"Device response did not match our challenge on ip {self._host}, " + f"check that your e-mail and password (both case-sensitive) are correct. " + ) _LOGGER.debug(msg) + raise AuthenticationError(msg) async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: """Perform the handshake.""" - _LOGGER.debug("Will to send handshake1...") + _LOGGER.debug("Sending handshake1...") body = { "method": "login", @@ -447,7 +639,7 @@ async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: ssl=await self._get_ssl_context(), ) - _LOGGER.debug("Device responded with: %s", resp_dict) + _LOGGER.debug("Device responded with status %s: %s", status_code, resp_dict) if status_code != 200: raise KasaException( @@ -462,7 +654,10 @@ async def send(self, request: str) -> dict[str, Any]: if self._state is TransportState.HANDSHAKE_REQUIRED: await self.perform_handshake() - return await self.send_secure_passthrough(request) + if self._send_secure: + return await self.send_secure_passthrough(request) + + return await self.send_unencrypted(request) async def close(self) -> None: """Close the http client and reset internal state.""" diff --git a/kasa/transports/ssltransport.py b/kasa/transports/ssltransport.py new file mode 100644 index 000000000..e4fef9a31 --- /dev/null +++ b/kasa/transports/ssltransport.py @@ -0,0 +1,235 @@ +"""Implementation of the clear-text passthrough ssl transport. + +This transport does not encrypt the passthrough payloads at all, but requires a login. +This has been seen on some devices (like robovacs). +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import logging +import time +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, cast + +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads +from kasa.transports import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + +def _md5_hash(payload: bytes) -> str: + return hashlib.md5(payload).hexdigest().upper() # noqa: S324 + + +class TransportState(Enum): + """Enum for transport state.""" + + LOGIN_REQUIRED = auto() # Login needed + ESTABLISHED = auto() # Ready to send requests + + +class SslTransport(BaseTransport): + """Implementation of the cleartext transport protocol. + + This transport uses HTTPS without any further payload encryption. + """ + + DEFAULT_PORT: int = 4433 + COMMON_HEADERS = { + "Content-Type": "application/json", + } + BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + super().__init__(config=config) + + if ( + not self._credentials or self._credentials.username is None + ) and not self._credentials_hash: + self._credentials = Credentials() + + if self._credentials: + self._login_params = self._get_login_params(self._credentials) + else: + self._login_params = json_loads( + base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] + ) + + self._default_credentials: Credentials | None = None + self._http_client: HttpClient = HttpClient(config) + + self._state = TransportState.LOGIN_REQUIRED + self._session_expire_at: float | None = None + + self._app_url = URL(f"https://{self._host}:{self._port}/app") + + _LOGGER.debug("Created ssltransport for %s", self._host) + + @property + def default_port(self) -> int: + """Default port for the transport.""" + if port := self._config.connection_type.http_port: + return port + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str: + """The hashed credentials used by the transport.""" + return base64.b64encode(json_dumps(self._login_params).encode()).decode() + + def _get_login_params(self, credentials: Credentials) -> dict[str, str]: + """Get the login parameters based on the login_version.""" + un, pw = self.hash_credentials(credentials) + return {"password": pw, "username": un} + + @staticmethod + def hash_credentials(credentials: Credentials) -> tuple[str, str]: + """Hash the credentials.""" + un = credentials.username + pw = _md5_hash(credentials.password.encode()) + return un, pw + + async def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: + """Handle response errors to request reauth etc.""" + error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + if error_code == SmartErrorCode.SUCCESS: + return + + msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" + + if error_code in SMART_RETRYABLE_ERRORS: + raise _RetryableError(msg, error_code=error_code) + + if error_code in SMART_AUTHENTICATION_ERRORS: + await self.reset() + raise AuthenticationError(msg, error_code=error_code) + + raise DeviceError(msg, error_code=error_code) + + async def send_request(self, request: str) -> dict[str, Any]: + """Send request.""" + url = self._app_url + + _LOGGER.debug("Sending %s to %s", request, url) + + status_code, resp_dict = await self._http_client.post( + url, + json=request, + headers=self.COMMON_HEADERS, + ) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code}" + ) + + _LOGGER.debug("Response with %s: %r", status_code, resp_dict) + + await self._handle_response_error_code(resp_dict, "Error sending request") + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + + return resp_dict + + async def perform_login(self) -> None: + """Login to the device.""" + try: + await self.try_login(self._login_params) + except AuthenticationError as aex: + try: + if aex.error_code is not SmartErrorCode.LOGIN_ERROR: + raise aex + + _LOGGER.debug("Login failed, going to try default credentials") + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_LOGIN_ERROR) + + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default credentials", + self._host, + ) + except AuthenticationError: + raise + except Exception as ex: + raise KasaException( + "Unable to login and trying default " + + f"login raised another exception: {ex}", + ex, + ) from ex + + async def try_login(self, login_params: dict[str, Any]) -> None: + """Try to login with supplied login_params.""" + login_request = { + "method": "login", + "params": login_params, + } + request = json_dumps(login_request) + _LOGGER.debug("Going to send login request") + + resp_dict = await self.send_request(request) + await self._handle_response_error_code(resp_dict, "Error logging in") + + login_token = resp_dict["result"]["token"] + self._app_url = self._app_url.with_query(f"token={login_token}") + self._state = TransportState.ESTABLISHED + self._session_expire_at = ( + time.time() + ONE_DAY_SECONDS - SESSION_EXPIRE_BUFFER_SECONDS + ) + + def _session_expired(self) -> bool: + """Return true if session has expired.""" + return ( + self._session_expire_at is None + or self._session_expire_at - time.time() <= 0 + ) + + async def send(self, request: str) -> dict[str, Any]: + """Send the request.""" + _LOGGER.debug("Going to send %s", request) + if self._state is not TransportState.ESTABLISHED or self._session_expired(): + _LOGGER.debug("Transport not established or session expired, logging in") + await self.perform_login() + + return await self.send_request(request) + + async def close(self) -> None: + """Close the http client and reset internal state.""" + await self.reset() + await self._http_client.close() + + async def reset(self) -> None: + """Reset internal login state.""" + self._state = TransportState.LOGIN_REQUIRED + self._app_url = URL(f"https://{self._host}:{self._port}/app") diff --git a/kasa/transports/xortransport.py b/kasa/transports/xortransport.py index 77a232f09..da77c899d 100644 --- a/kasa/transports/xortransport.py +++ b/kasa/transports/xortransport.py @@ -23,12 +23,18 @@ from kasa.deviceconfig import DeviceConfig from kasa.exceptions import KasaException, _RetryableError +from kasa.exceptions import TimeoutError as KasaTimeoutError from kasa.json import loads as json_loads from .basetransport import BaseTransport _LOGGER = logging.getLogger(__name__) -_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} +_NO_RETRY_ERRORS = { + errno.EHOSTDOWN, + errno.EHOSTUNREACH, + errno.ENETUNREACH, + errno.ECONNREFUSED, +} _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") @@ -126,6 +132,12 @@ async def send(self, request: str) -> dict: # This is especially import when there are multiple tplink devices being polled. try: await self._connect(self._timeout) + except TimeoutError as ex: + await self.reset() + raise KasaTimeoutError( + f"Timeout after {self._timeout} seconds connecting to the device:" + f" {self._host}:{self._port}: {ex}" + ) from ex except ConnectionRefusedError as ex: await self.reset() raise KasaException( @@ -135,18 +147,16 @@ async def send(self, request: str) -> dict: await self.reset() if ex.errno in _NO_RETRY_ERRORS: raise KasaException( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex else: raise _RetryableError( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except Exception as ex: await self.reset() raise _RetryableError( - f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except BaseException: # Likely something cancelled the task so we need to close the connection @@ -159,6 +169,12 @@ async def send(self, request: str) -> dict: assert self.writer is not None # noqa: S101 async with asyncio_timeout(self._timeout): return await self._execute_send(request) + except TimeoutError as ex: + await self.reset() + raise KasaTimeoutError( + f"Timeout after {self._timeout} seconds sending request to the device" + f" {self._host}:{self._port}: {ex}" + ) from ex except Exception as ex: await self.reset() raise _RetryableError( diff --git a/pyproject.toml b/pyproject.toml index 9dc265c8b..2866b9b2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-kasa" -version = "0.8.1" +version = "0.10.2" description = "Python API for TP-Link Kasa and Tapo devices" license = {text = "GPL-3.0-or-later"} authors = [ { name = "python-kasa developers" }] @@ -11,7 +11,7 @@ dependencies = [ "cryptography>=1.9", "aiohttp>=3", "tzdata>=2024.2 ; platform_system == 'Windows'", - "mashumaro>=3.14", + "mashumaro>=3.20" ] classifiers = [ @@ -21,17 +21,11 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.optional-dependencies] -speedups = ["orjson>=3.9.1", "kasa-crypt>=0.2.0"] -docs = [ - "sphinx_rtd_theme~=2.0", - "sphinxcontrib-programoutput~=0.0", - "myst-parser", - "docutils>=0.17", - "sphinx>=7.4.7", -] +speedups = ["orjson>=3.11.1", "kasa-crypt>=0.2.0"] shell = ["ptpython", "rich"] [project.urls] @@ -43,8 +37,8 @@ shell = ["ptpython", "rich"] [project.scripts] kasa = "kasa.cli.__main__:cli" -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "pytest", "pytest-cov", "pytest-asyncio", @@ -61,9 +55,16 @@ dev-dependencies = [ "mypy~=1.0", "pytest-xdist>=3.6.1", "pytest-socket>=0.7.0", - "ruff==0.7.4", + "ruff>=0.9.0", + "uv>=0.11.26", +] +docs = [ + "sphinx_rtd_theme~=2.0", + "sphinxcontrib-programoutput~=0.0", + "myst-parser", + "docutils>=0.17", + "sphinx>=7.4.7", ] - [build-system] requires = ["hatchling"] @@ -112,7 +113,7 @@ markers = [ ] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" -timeout = 10 +#timeout = 10 # dist=loadgroup enables grouping of tests into single worker. # required as caplog doesn't play nicely with multiple workers. addopts = "--disable-socket --allow-unix-socket --dist=loadgroup" @@ -146,8 +147,6 @@ select = [ ignore = [ "D105", # Missing docstring in magic method "D107", # Missing docstring in `__init__` - "ANN101", # Missing type annotation for `self` - "ANN102", # Missing type annotation for `cls` in classmethod "ANN003", # Missing type annotation for `**kwargs` "ANN401", # allow any ] @@ -184,7 +183,7 @@ convention = "pep257" warn_unused_configs = true # warns if overrides sections unused/mis-spelled [[tool.mypy.overrides]] -module = [ "kasa.tests.*", "devtools.*" ] +module = [ "tests.*", "devtools.*" ] disable_error_code = "annotation-unchecked" [[tool.mypy.overrides]] diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/test_hub.py b/tests/cli/test_hub.py new file mode 100644 index 000000000..c35308780 --- /dev/null +++ b/tests/cli/test_hub.py @@ -0,0 +1,61 @@ +import pytest +from asyncclick.testing import CliRunner +from pytest_mock import MockerFixture + +from kasa import Device, DeviceType, Module +from kasa.cli.hub import hub + +from ..device_fixtures import hubs, plug_iot + + +@hubs +async def test_hub_pair( + dev: Device, + mocker: MockerFixture, + runner: CliRunner, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that pair calls the expected methods.""" + cs = dev.modules.get(Module.ChildSetup) + # Patch if the device supports the module + if cs is not None: + mock_pair = mocker.patch.object(cs, "pair") + + res = await runner.invoke(hub, ["pair"], obj=dev, catch_exceptions=False) + if cs is None: + assert "is not a hub" in res.output + return + + mock_pair.assert_awaited() + assert "Finding new devices for 10 seconds" in res.output + assert res.exit_code == 0 + + +@hubs +async def test_hub_unpair( + dev: Device, mocker: MockerFixture, runner: CliRunner +) -> None: + """Test that unpair calls the expected method.""" + if not dev.children: + pytest.skip("Cannot test without child devices") + + id_ = next(iter(dev.children)).device_id + + cs = dev.modules.get(Module.ChildSetup) + mock_unpair = mocker.spy(cs, "unpair") + + res = await runner.invoke(hub, ["unpair", id_], obj=dev, catch_exceptions=False) + + mock_unpair.assert_awaited() + assert f"Unpaired {id_}" in res.output + assert res.exit_code == 0 + + +@plug_iot +async def test_non_hub(dev: Device, mocker: MockerFixture, runner: CliRunner) -> None: + """Test that hub commands return an error if executed on a non-hub.""" + assert dev.device_type is not DeviceType.Hub + res = await runner.invoke( + hub, ["unpair", "dummy_id"], obj=dev, catch_exceptions=False + ) + assert "is not a hub" in res.output diff --git a/tests/cli/test_vacuum.py b/tests/cli/test_vacuum.py new file mode 100644 index 000000000..d45764369 --- /dev/null +++ b/tests/cli/test_vacuum.py @@ -0,0 +1,123 @@ +from asyncclick.testing import CliRunner +from pytest_mock import MockerFixture + +from kasa import Device, DeviceType, Module +from kasa.cli.vacuum import vacuum + +from ..device_fixtures import plug_iot +from ..device_fixtures import vacuum as vacuum_devices + + +@vacuum_devices +async def test_vacuum_records_group( + dev: Device, mocker: MockerFixture, runner: CliRunner +) -> None: + """Test that vacuum records calls the expected methods.""" + rec = dev.modules.get(Module.CleanRecords) + assert rec + + res = await runner.invoke(vacuum, ["records"], obj=dev, catch_exceptions=False) + + latest = rec.parsed_data.last_clean + expected = ( + f"Totals: {rec.total_clean_area} {rec.area_unit} in {rec.total_clean_time} " + f"(cleaned {rec.total_clean_count} times)\n" + f"Last clean: {latest.clean_area} {rec.area_unit} @ {latest.clean_time}" + ) + assert expected in res.output + assert res.exit_code == 0 + + +@vacuum_devices +async def test_vacuum_records_list( + dev: Device, mocker: MockerFixture, runner: CliRunner +) -> None: + """Test that vacuum records list calls the expected methods.""" + rec = dev.modules.get(Module.CleanRecords) + assert rec + + res = await runner.invoke( + vacuum, ["records", "list"], obj=dev, catch_exceptions=False + ) + + data = rec.parsed_data + for record in data.records: + expected = ( + f"* {record.timestamp}: cleaned {record.clean_area} {rec.area_unit}" + f" in {record.clean_time}" + ) + assert expected in res.output + assert res.exit_code == 0 + + +@vacuum_devices +async def test_vacuum_consumables(dev: Device, runner: CliRunner) -> None: + """Test that vacuum consumables calls the expected methods.""" + cons = dev.modules.get(Module.Consumables) + assert cons + + res = await runner.invoke(vacuum, ["consumables"], obj=dev, catch_exceptions=False) + + expected = "" + for c in cons.consumables.values(): + expected += f"{c.name} ({c.id}): {c.used} used, {c.remaining} remaining\n" + + assert expected in res.output + assert res.exit_code == 0 + + +@vacuum_devices +async def test_vacuum_consumables_reset( + dev: Device, mocker: MockerFixture, runner: CliRunner +) -> None: + """Test that vacuum consumables reset calls the expected methods.""" + cons = dev.modules.get(Module.Consumables) + assert cons + + reset_consumable_mock = mocker.spy(cons, "reset_consumable") + for c_id in cons.consumables: + reset_consumable_mock.reset_mock() + res = await runner.invoke( + vacuum, ["consumables", "reset", c_id], obj=dev, catch_exceptions=False + ) + reset_consumable_mock.assert_awaited_once_with(c_id) + assert f"Consumable {c_id} reset" in res.output + assert res.exit_code == 0 + + res = await runner.invoke( + vacuum, ["consumables", "reset", "foobar"], obj=dev, catch_exceptions=False + ) + expected = ( + "Consumable foobar not found in " + f"device consumables: {', '.join(cons.consumables.keys())}." + ) + assert expected in res.output.replace("\n", "") + assert res.exit_code != 0 + + +@plug_iot +async def test_non_vacuum( + dev: Device, mocker: MockerFixture, runner: CliRunner +) -> None: + """Test that vacuum commands return an error if executed on a non-vacuum.""" + assert dev.device_type is not DeviceType.Vacuum + + res = await runner.invoke(vacuum, ["records"], obj=dev, catch_exceptions=False) + assert "This device does not support records" in res.output + assert res.exit_code != 0 + + res = await runner.invoke( + vacuum, ["records", "list"], obj=dev, catch_exceptions=False + ) + assert "This device does not support records" in res.output + assert res.exit_code != 0 + + res = await runner.invoke(vacuum, ["consumables"], obj=dev, catch_exceptions=False) + assert "This device does not support consumables" in res.output + assert res.exit_code != 0 + + res = await runner.invoke( + vacuum, ["consumables", "reset", "foobar"], obj=dev, catch_exceptions=False + ) + assert "This device does not support consumables" in res.output + assert res.exit_code != 0 diff --git a/tests/conftest.py b/tests/conftest.py index 3da689c5b..0d97f16a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,24 @@ from __future__ import annotations import asyncio +import functools +import os import sys import warnings from pathlib import Path from unittest.mock import MagicMock, patch +import aiohttp import pytest +# TODO: this and runner fixture could be moved to tests/cli/conftest.py +from asyncclick.testing import CliRunner + from kasa import ( DeviceConfig, SmartProtocol, ) +from kasa.httpclient import HttpClient from kasa.transports.basetransport import BaseTransport from .device_fixtures import * # noqa: F403 @@ -22,6 +29,45 @@ turn_on = pytest.mark.parametrize("turn_on", [True, False]) +@pytest.fixture(autouse=True) +async def _close_transport_and_http_sessions(monkeypatch): + """Ensure all transports and http clients close their sessions after tests.""" + transports: list[BaseTransport] = [] + http_clients: list[HttpClient] = [] + aiohttp_sessions: list[aiohttp.ClientSession] = [] + + original_transport_init = BaseTransport.__init__ + original_http_init = HttpClient.__init__ + original_session_init = aiohttp.ClientSession.__init__ + + @functools.wraps(original_transport_init) + def _track_transport(self, *args, **kwargs): + original_transport_init(self, *args, **kwargs) + transports.append(self) + + @functools.wraps(original_http_init) + def _track_http(self, *args, **kwargs): + original_http_init(self, *args, **kwargs) + http_clients.append(self) + + @functools.wraps(original_session_init) + def _track_session(self, *args, **kwargs): + original_session_init(self, *args, **kwargs) + aiohttp_sessions.append(self) + + monkeypatch.setattr(BaseTransport, "__init__", _track_transport) + monkeypatch.setattr(HttpClient, "__init__", _track_http) + monkeypatch.setattr(aiohttp.ClientSession, "__init__", _track_session) + yield + for transport in transports: + await transport.close() + for client in http_clients: + await client.close() + for session in aiohttp_sessions: + if not session.closed: + await session.close() + + def load_fixture(foldername, filename): """Load a fixture.""" path = Path(Path(__file__).parent / "fixtures" / foldername / filename) @@ -149,3 +195,12 @@ async def _create_datagram_endpoint(protocol_factory, *_, **__): side_effect=_create_datagram_endpoint, ): yield + + +@pytest.fixture +def runner(): + """Runner fixture that unsets the KASA_ environment variables for tests.""" + KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} + runner = CliRunner(env=KASA_VARS) + + return runner diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 2af0ca065..0edce51e9 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -27,9 +27,16 @@ ) # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} +BULBS_SMART_VARIABLE_TEMP = {"L430C", "L430P", "L530E", "L530EA", "L535E", "L930-5"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_COLOR = { + "L430C", + "L430P", + "L530E", + "L530EA", + "L535E", + *BULBS_SMART_LIGHT_STRIP, +} BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) @@ -38,7 +45,7 @@ ) # Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL430", "KL420L5"} +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} BULBS_IOT_VARIABLE_TEMP = { "LB120", "LB130", @@ -48,8 +55,11 @@ "KL135", "KL430", } +# KL400L5 firmware 1.0.5 and 1.0.8 fixtures report no variable color temperature +# support, so match the supporting 1.0.11 fixture rather than the entire model. +BULBS_IOT_VARIABLE_TEMP_FIXTURES = {"KL400L5(US)_1.0_1.0.11"} BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110", "KL110B"} BULBS_IOT = ( BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) .union(BULBS_IOT_DIMMABLE) @@ -73,15 +83,15 @@ "HS105", "HS110", "EP10", + "EP25", "KP100", "KP105", "KP115", "KP125", "KP401", } -# P135 supports dimming, but its not currently support -# by the library PLUGS_SMART = { + "P105", "P100", "P110", "P110M", @@ -89,6 +99,7 @@ "KP125M", "EP25", "P125M", + "TP10", "TP15", } PLUGS = { @@ -98,6 +109,7 @@ SWITCHES_IOT = { "HS200", "HS210", + "KS200", "KS200M", } SWITCHES_SMART = { @@ -105,28 +117,44 @@ "KS205", "KS225", "KS240", + "S500", "S500D", "S505", "S505D", + "S515D", + "TS15", } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"} STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} +DIMMERS_SMART = {"HS220", "KS225", "S500D", "S505D", "S515D", "P135"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110", "S200B", "S200D"} +SENSORS_SMART = { + "T310", + "T315", + "T300", + "T100", + "T110", + "S200B", + "S200D", + "S210", + "S220", + "D100C", # needs a home category? +} THERMOSTATS_SMART = {"KE100"} -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} +VACUUMS_SMART = {"RV20"} + +WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M", "S515D"} WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} DIMMABLE = {*BULBS, *DIMMERS} @@ -142,6 +170,7 @@ .union(SENSORS_SMART) .union(SWITCHES_SMART) .union(THERMOSTATS_SMART) + .union(VACUUMS_SMART) ) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) @@ -217,6 +246,9 @@ def parametrize( model_filter=ALL_DEVICES - WITH_EMETER, protocol_filter={"SMART", "IOT"}, ) +has_emeter_smart = parametrize( + "has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"} +) has_emeter_iot = parametrize( "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} ) @@ -245,16 +277,23 @@ def parametrize( non_dimmable_iot = parametrize( "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} ) -variable_temp = parametrize( +variable_temp_by_model = parametrize( "variable color temp", model_filter=BULBS_VARIABLE_TEMP, protocol_filter={"SMART", "IOT"}, ) +variable_temp_by_fixture = parametrize( + "variable color temp fixtures", + model_filter=BULBS_IOT_VARIABLE_TEMP_FIXTURES, + protocol_filter={"IOT"}, +) +variable_temp = parametrize_combine([variable_temp_by_model, variable_temp_by_fixture]) non_variable_temp = parametrize( "non-variable color temp", model_filter=BULBS - BULBS_VARIABLE_TEMP, protocol_filter={"SMART", "IOT"}, ) +non_variable_temp = parametrize_subtract(non_variable_temp, variable_temp_by_fixture) color_bulb = parametrize( "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} ) @@ -267,11 +306,14 @@ def parametrize( color_bulb_iot = parametrize( "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} ) -variable_temp_iot = parametrize( +variable_temp_iot_by_model = parametrize( "variable color temp iot", model_filter=BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"}, ) +variable_temp_iot = parametrize_combine( + [variable_temp_iot_by_model, variable_temp_by_fixture] +) variable_temp_smart = parametrize( "variable color temp smart", model_filter=BULBS_SMART_VARIABLE_TEMP, @@ -291,6 +333,11 @@ def parametrize( strip_iot = parametrize( "strip devices iot", model_filter=STRIPS_IOT, protocol_filter={"IOT"} ) +strip_emeter_iot = parametrize( + "strip devices iot with emeter", + model_filter=STRIPS_IOT & WITH_EMETER_IOT, + protocol_filter={"IOT"}, +) strip_smart = parametrize( "strip devices smart", model_filter=STRIPS_SMART, protocol_filter={"SMART"} ) @@ -323,13 +370,25 @@ def parametrize( camera_smartcam = parametrize( "camera smartcam", device_type_filter=[DeviceType.Camera], - protocol_filter={"SMARTCAM"}, + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, ) hub_smartcam = parametrize( "hub smartcam", device_type_filter=[DeviceType.Hub], protocol_filter={"SMARTCAM"}, ) +hubs = parametrize_combine([hubs_smart, hub_smartcam]) +doobell_smartcam = parametrize( + "doorbell smartcam", + device_type_filter=[DeviceType.Doorbell], + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, +) +chime_smart = parametrize( + "chime smart", + device_type_filter=[DeviceType.Chime], + protocol_filter={"SMART"}, +) +vacuum = parametrize("vacuums", device_type_filter=[DeviceType.Vacuum]) def check_categories(): @@ -346,8 +405,11 @@ def check_categories(): + hubs_smart.args[1] + sensors_smart.args[1] + thermostats_smart.args[1] + + chime_smart.args[1] + camera_smartcam.args[1] + + doobell_smartcam.args[1] + hub_smartcam.args[1] + + vacuum.args[1] ) diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures) if diffs: @@ -365,7 +427,7 @@ def check_categories(): def device_for_fixture_name(model, protocol): if protocol in {"SMART", "SMART.CHILD"}: return SmartDevice - elif protocol == "SMARTCAM": + elif protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: return SmartCamDevice else: for d in STRIPS_IOT: @@ -418,11 +480,20 @@ async def get_device_for_fixture( d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( host="127.0.0.123" ) + + # smart child devices sometimes check _is_hub_child which needs a parent + # of DeviceType.Hub + class DummyParent: + device_type = DeviceType.Hub + + if fixture_data.protocol in {"SMARTCAM.CHILD"}: + d._parent = DummyParent() + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: d.protocol = FakeSmartProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim ) - elif fixture_data.protocol == "SMARTCAM": + elif fixture_data.protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: d.protocol = FakeSmartCamProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim ) @@ -431,7 +502,7 @@ async def get_device_for_fixture( discovery_data = None if "discovery_result" in fixture_data.data: - discovery_data = fixture_data.data["discovery_result"] + discovery_data = fixture_data.data["discovery_result"]["result"] elif "system" in fixture_data.data: discovery_data = { "system": {"get_sysinfo": fixture_data.data["system"]["get_sysinfo"]} @@ -469,8 +540,12 @@ def get_nearest_fixture_to_ip(dev): assert protocol_fixtures, "Unknown device type" # This will get the best fixture with a match on model region - if model_region_fixtures := filter_fixtures( - "", model_filter={dev._model_region}, fixture_list=protocol_fixtures + if (di := dev.device_info) and ( + model_region_fixtures := filter_fixtures( + "", + model_filter={di.long_name + (f"({di.region})" if di.region else "")}, + fixture_list=protocol_fixtures, + ) ): return next(iter(model_region_fixtures)) diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 15109b3bf..3cf726f48 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import copy +from collections.abc import Coroutine from dataclasses import dataclass from json import dumps as json_dumps from typing import Any, TypedDict @@ -22,6 +24,29 @@ class DiscoveryResponse(TypedDict): error_code: int +UNSUPPORTED_HOMEWIFISYSTEM = { + "error_code": 0, + "result": { + "channel_2g": "10", + "channel_5g": "44", + "device_id": "REDACTED_51f72a752213a6c45203530", + "device_model": "X20", + "device_type": "HOMEWIFISYSTEM", + "factory_default": False, + "group_id": "REDACTED_07d902da02fa9beab8a64", + "group_name": "I01BU0tFRF9TU0lEIw==", # '#MASKED_SSID#' + "hardware_version": "3.0", + "ip": "127.0.0.1", + "mac": "24:2F:D0:00:00:00", + "master_device_id": "REDACTED_51f72a752213a6c45203530", + "need_account_digest": True, + "owner": "REDACTED_341c020d7e8bda184e56a90", + "role": "master", + "tmp_port": [20001], + }, +} + + def _make_unsupported( device_family, encrypt_type, @@ -75,13 +100,14 @@ def _make_unsupported( "unable_to_parse": _make_unsupported( "SMART.TAPOBULB", "FOO", - omit_keys={"mgt_encrypt_schm": None}, + omit_keys={"device_id": None}, ), "invalidinstance": _make_unsupported( "IOT.SMARTPLUGSWITCH", "KLAP", https=True, ), + "homewifi": UNSUPPORTED_HOMEWIFISYSTEM, } @@ -106,14 +132,19 @@ def parametrize_discovery( "new discovery", data_root_filter="discovery_result" ) +smart_discovery = parametrize_discovery("smart discovery", protocol_filter={"SMART"}) + @pytest.fixture( - params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + params=filter_fixtures( + "discoverable", protocol_filter={"SMART", "SMARTCAM", "IOT"} + ), ids=idgenerator, ) async def discovery_mock(request, mocker): """Mock discovery and patch protocol queries to use Fake protocols.""" - fixture_info: FixtureInfo = request.param + fi: FixtureInfo = request.param + fixture_info = FixtureInfo(fi.name, fi.protocol, copy.deepcopy(fi.data)) return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) @@ -132,6 +163,18 @@ class _DiscoveryMock: https: bool login_version: int | None = None port_override: int | None = None + http_port: int | None = None + + @property + def model(self) -> str: + dd = self.discovery_data + model_region = ( + dd["result"]["device_model"] + if self.discovery_port == 20002 + else dd["system"]["get_sysinfo"]["model"] + ) + model, _, _ = model_region.partition("(") + return model @property def _datagram(self) -> bytes: @@ -144,18 +187,27 @@ def _datagram(self) -> bytes: ) if "discovery_result" in fixture_data: - discovery_data = {"result": fixture_data["discovery_result"].copy()} - discovery_result = fixture_data["discovery_result"] + discovery_data = fixture_data["discovery_result"].copy() + discovery_result = fixture_data["discovery_result"]["result"] device_type = discovery_result["device_type"] encrypt_type = discovery_result["mgt_encrypt_schm"].get( "encrypt_type", discovery_result.get("encrypt_info", {}).get("sym_schm") ) - login_version = discovery_result["mgt_encrypt_schm"].get("lv") + if not (login_version := discovery_result["mgt_encrypt_schm"].get("lv")) and ( + et := discovery_result.get("encrypt_type") + ): + login_version = max([int(i) for i in et]) https = discovery_result["mgt_encrypt_schm"]["is_support_https"] + http_port = discovery_result["mgt_encrypt_schm"].get("http_port") + if not http_port: # noqa: SIM108 + # Not all discovery responses set the http port, i.e. smartcam. + default_port = 443 if https else 80 + else: + default_port = http_port dm = _DiscoveryMock( ip, - 80, + default_port, 20002, discovery_data, fixture_data, @@ -163,6 +215,7 @@ def _datagram(self) -> bytes: encrypt_type, https, login_version, + http_port=http_port, ) else: sys_info = fixture_data["system"]["get_sysinfo"] @@ -202,12 +255,46 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): first_ip = list(fixture_infos.keys())[0] first_host = None + # Mock _run_callback_task so the tasks complete in the order they started. + # Otherwise test output is non-deterministic which affects readme examples. + callback_queue: asyncio.Queue = asyncio.Queue() + exception_queue: asyncio.Queue = asyncio.Queue() + + async def process_callback_queue(finished_event: asyncio.Event) -> None: + while (finished_event.is_set() is False) or callback_queue.qsize(): + coro = await callback_queue.get() + try: + await coro + except Exception as ex: + await exception_queue.put(ex) + else: + await exception_queue.put(None) + callback_queue.task_done() + + async def wait_for_coro(): + await callback_queue.join() + if ex := exception_queue.get_nowait(): + raise ex + + def _run_callback_task(self, coro: Coroutine) -> None: + callback_queue.put_nowait(coro) + task = asyncio.create_task(wait_for_coro()) + self.callback_tasks.append(task) + + mocker.patch( + "kasa.discover._DiscoverProtocol._run_callback_task", _run_callback_task + ) + + # do_discover_mock async def mock_discover(self): """Call datagram_received for all mock fixtures. Handles test cases modifying the ip and hostname of the first fixture for discover_single testing. """ + finished_event = asyncio.Event() + asyncio.create_task(process_callback_queue(finished_event)) + for ip, dm in discovery_mocks.items(): first_ip = list(discovery_mocks.values())[0].ip fixture_info = fixture_infos[ip] @@ -234,10 +321,18 @@ async def mock_discover(self): dm._datagram, (dm.ip, port), ) + # Setting this event will stop the processing of callbacks + finished_event.set() + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + # query_mock async def _query(self, request, retry_count: int = 3): return await protos[self._host].query(request) + mocker.patch("kasa.IotProtocol.query", _query) + mocker.patch("kasa.SmartProtocol.query", _query) + def _getaddrinfo(host, *_, **__): nonlocal first_host, first_ip first_host = host # Store the hostname used by discover single @@ -246,20 +341,21 @@ def _getaddrinfo(host, *_, **__): ].ip # ip could have been overridden in test return [(None, None, None, None, (first_ip, 0))] - mocker.patch("kasa.IotProtocol.query", _query) - mocker.patch("kasa.SmartProtocol.query", _query) - mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - mocker.patch( - "socket.getaddrinfo", - # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))], - side_effect=_getaddrinfo, - ) + mocker.patch("socket.getaddrinfo", side_effect=_getaddrinfo) + + # Mock decrypt so it doesn't error with unencryptable empty data in the + # fixtures. The discovery result will already contain the decrypted data + # deserialized from the fixture + mocker.patch("kasa.discover.Discover._decrypt_discovery_data") + # Only return the first discovery mock to be used for testing discover single return discovery_mocks[first_ip] @pytest.fixture( - params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + params=filter_fixtures( + "discoverable", protocol_filter={"SMART", "SMARTCAM", "IOT"} + ), ids=idgenerator, ) def discovery_data(request, mocker): @@ -279,7 +375,7 @@ def discovery_data(request, mocker): mocker.patch("kasa.IotProtocol.query", return_value=fixture_data) mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data) if "discovery_result" in fixture_data: - return {"result": fixture_data["discovery_result"]} + return fixture_data["discovery_result"].copy() else: return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py index 88e34647a..ed3b5b102 100644 --- a/tests/fakeprotocol_iot.py +++ b/tests/fakeprotocol_iot.py @@ -190,8 +190,8 @@ def success(res): }, } - MOTION_MODULE = { + "get_adc_value": {"value": 50, "err_code": 0}, "get_config": { "enable": 0, "version": "1.0", @@ -201,7 +201,7 @@ def success(res): "max_adc": 4095, "array": [80, 50, 20, 0], "err_code": 0, - } + }, } LIGHT_DETAILS = { @@ -308,10 +308,6 @@ def set_relay_state(self, x, child_ids=None): child_ids = [] _LOGGER.debug("Setting relay state to %s", x["state"]) - if not child_ids and "children" in self.proto["system"]["get_sysinfo"]: - for child in self.proto["system"]["get_sysinfo"]["children"]: - child_ids.append(child["id"]) - _LOGGER.info("child_ids: %s", child_ids) if child_ids: for child in self.proto["system"]["get_sysinfo"]["children"]: diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 448729ca7..257e07ea2 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -7,6 +7,8 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.exceptions import SmartErrorCode from kasa.smart import SmartChildDevice +from kasa.smartcam import SmartCamChild +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT from kasa.transports.basetransport import BaseTransport @@ -48,13 +50,18 @@ def __init__( ), ) self.fixture_name = fixture_name + + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + self.verbatim = verbatim + # Don't copy the dict if the device is a child so that updates on the # child are then still reflected on the parent's lis of child device in if not is_child: self.info = copy.deepcopy(info) if get_child_fixtures: self.child_protocols = self._get_child_protocols( - self.info, self.fixture_name, "get_child_device_list" + self.info, self.fixture_name, "get_child_device_list", self.verbatim ) else: self.info = info @@ -67,9 +74,6 @@ def __init__( self.warn_fixture_missing_methods = warn_fixture_missing_methods self.fix_incomplete_fixture_lists = fix_incomplete_fixture_lists - # When True verbatim will bypass any extra processing of missing - # methods and is used to test the fixture creation itself. - self.verbatim = verbatim if verbatim: self.warn_fixture_missing_methods = False self.fix_incomplete_fixture_lists = False @@ -114,18 +118,25 @@ def credentials_hash(self): "type": 0, }, ), + "get_homekit_info": ( + "homekit", + { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "000000000000000000000000000000000", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000", + }, + ), "get_auto_update_info": ( - "firmware", + ("firmware", 2), {"enable": True, "random_range": 120, "time": 180}, ), "get_alarm_configure": ( "alarm", { - "get_alarm_configure": { - "duration": 10, - "type": "Doorbell Ring 2", - "volume": "low", - } + "duration": 10, + "type": "Doorbell Ring 2", + "volume": "low", }, ), "get_support_alarm_type_list": ( @@ -151,8 +162,66 @@ def credentials_hash(self): "energy_monitoring", {"igain": 10861, "vgain": 118657}, ), + "get_protection_power": ( + "power_protection", + {"enabled": False, "protection_power": 0}, + ), + "get_max_power": ( + "power_protection", + {"max_power": 3904}, + ), + "get_matter_setup_info": ( + "matter", + { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000", + }, + ), + # child setup + "get_support_child_device_category": ( + "child_quick_setup", + {"device_category_list": [{"category": "subg.trv"}]}, + ), + "get_scan_child_device_list": ( + "child_quick_setup", + { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw==", + } + ], + "scan_status": "idle", + }, + ), } + def _missing_result(self, method): + """Check the FIXTURE_MISSING_MAP for responses. + + Fixtures generated prior to a query being supported by dump_devinfo + do not have the response so this method checks whether the component + is supported and fills in the missing response. + If the first value of the lookup value is a tuple it will also check + the version, i.e. (component_name, component_version). + """ + if not (missing := self.FIXTURE_MISSING_MAP.get(method)): + return None + condition = missing[0] + if ( + isinstance(condition, tuple) + and (version := self.components.get(condition[0])) + and version >= condition[1] + ): + return copy.deepcopy(missing[1]) + + if condition in self.components: + return copy.deepcopy(missing[1]) + + return None + async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] @@ -173,7 +242,7 @@ async def send(self, request: str): @staticmethod def _get_child_protocols( - parent_fixture_info, parent_fixture_name, child_devices_key + parent_fixture_info, parent_fixture_name, child_devices_key, verbatim ): child_infos = parent_fixture_info.get(child_devices_key, {}).get( "child_device_list", [] @@ -185,16 +254,20 @@ def _get_child_protocols( # imported here to avoid circular import from .conftest import filter_fixtures - def try_get_child_fixture_info(child_dev_info): + def try_get_child_fixture_info(child_dev_info, protocol): hw_version = child_dev_info["hw_ver"] - sw_version = child_dev_info["fw_ver"] + sw_version = child_dev_info.get("sw_ver", child_dev_info.get("fw_ver")) sw_version = sw_version.split(" ")[0] - model = child_dev_info["model"] - region = child_dev_info.get("specs", "XX") - child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" + model = child_dev_info.get("device_model", child_dev_info.get("model")) + assert sw_version + assert model + + region = child_dev_info.get("specs", child_dev_info.get("region")) + region = f"({region})" if region else "" + child_fixture_name = f"{model}{region}_{hw_version}_{sw_version}" child_fixtures = filter_fixtures( "Child fixture", - protocol_filter={"SMART.CHILD"}, + protocol_filter={protocol}, model_filter={child_fixture_name}, ) if child_fixtures: @@ -207,12 +280,17 @@ def try_get_child_fixture_info(child_dev_info): and (category := child_info.get("category")) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP ): - if fixture_info_tuple := try_get_child_fixture_info(child_info): + if fixture_info_tuple := try_get_child_fixture_info( + child_info, "SMART.CHILD" + ): child_fixture = copy.deepcopy(fixture_info_tuple.data) child_fixture["get_device_info"]["device_id"] = device_id found_child_fixture_infos.append(child_fixture["get_device_info"]) child_protocols[device_id] = FakeSmartProtocol( - child_fixture, fixture_info_tuple.name, is_child=True + child_fixture, + fixture_info_tuple.name, + is_child=True, + verbatim=verbatim, ) # Look for fixture inline elif (child_fixtures := parent_fixture_info.get("child_devices")) and ( @@ -223,19 +301,46 @@ def try_get_child_fixture_info(child_dev_info): child_fixture, f"{parent_fixture_name}-{device_id}", is_child=True, + verbatim=verbatim, ) else: pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] parent_fixture_name, set() ).add("child_devices") + elif ( + (device_id := child_info.get("device_id")) + and (category := child_info.get("category")) + and category in SmartCamChild.CHILD_DEVICE_TYPE_MAP + and ( + fixture_info_tuple := try_get_child_fixture_info( + child_info, "SMARTCAM.CHILD" + ) + ) + ): + from .fakeprotocol_smartcam import FakeSmartCamProtocol + + child_fixture = copy.deepcopy(fixture_info_tuple.data) + child_fixture["getDeviceInfo"]["device_info"]["basic_info"][ + "dev_id" + ] = device_id + child_fixture[CHILD_INFO_FROM_PARENT]["device_id"] = device_id + # We copy the child device info to the parent getChildDeviceInfo + # list for smartcam children in order for updates to work. + found_child_fixture_infos.append(child_fixture[CHILD_INFO_FROM_PARENT]) + child_protocols[device_id] = FakeSmartCamProtocol( + child_fixture, + fixture_info_tuple.name, + is_child=True, + verbatim=verbatim, + ) else: warn( - f"Child is a cameraprotocol which needs to be implemented {child_info}", + f"Child is a protocol which needs to be implemented {child_info}", stacklevel=2, ) # Replace parent child infos with the infos from the child fixtures so # that updates update both - if child_infos and found_child_fixture_infos: + if not verbatim and child_infos and found_child_fixture_infos: parent_fixture_info[child_devices_key]["child_device_list"] = ( found_child_fixture_infos ) @@ -302,18 +407,16 @@ def _handle_control_child_missing(self, params: dict): elif child_method in child_device_calls: result = copy.deepcopy(child_device_calls[child_method]) return {"result": result, "error_code": 0} - elif ( + elif missing_result := self._missing_result(child_method): # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated - missing_result := self.FIXTURE_MISSING_MAP.get(child_method) - ) and missing_result[0] in self.components: # Copy to info so it will work with update methods - child_device_calls[child_method] = copy.deepcopy(missing_result[1]) + child_device_calls[child_method] = missing_result result = copy.deepcopy(info[child_method]) retval = {"result": result, "error_code": 0} return retval - elif child_method[:4] == "set_": - target_method = f"get_{child_method[4:]}" + elif child_method[:3] == "set": + target_method = f"get{child_method[3:]}" if target_method not in child_device_calls: raise RuntimeError( f"No {target_method} in child info, calling set before get not supported." @@ -470,6 +573,48 @@ def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: return {"error_code": 0} + def _hub_remove_device(self, info, params): + """Remove hub device.""" + items_to_remove = [dev["device_id"] for dev in params["child_device_list"]] + children = info["get_child_device_list"]["child_device_list"] + new_children = [ + dev for dev in children if dev["device_id"] not in items_to_remove + ] + info["get_child_device_list"]["child_device_list"] = new_children + + return {"error_code": 0} + + def get_child_device_queries(self, method, params): + return self._get_method_from_info(method, params) + + def _get_method_from_info(self, method, params): + result = copy.deepcopy(self.info[method]) + if result and "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + # Fixtures generated before _handle_response_lists was implemented + # could have incomplete lists. + if ( + len(result[list_key]) < result["sum"] + and self.fix_incomplete_fixture_lists + ): + result["sum"] = len(result[list_key]) + if self.warn_fixture_missing_methods: + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] + self.fixture_name, set() + ).add(f"{method} (incomplete '{list_key}' list)") + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + async def _send_request(self, request_dict: dict): method = request_dict["method"] @@ -478,34 +623,17 @@ async def _send_request(self, request_dict: dict): return await self._handle_control_child(request_dict["params"]) params = request_dict.get("params", {}) - if method in {"component_nego", "qs_component_nego"} or method[:4] == "get_": + if method in {"component_nego", "qs_component_nego"} or method[:3] == "get": + # These methods are handled in get_child_device_query so it can be + # patched for tests to simulate dynamic devices. + if ( + method in ("get_child_device_list", "get_child_device_component_list") + and method in info + ): + return self.get_child_device_queries(method, params) + if method in info: - result = copy.deepcopy(info[method]) - if result and "start_index" in result and "sum" in result: - list_key = next( - iter([key for key in result if isinstance(result[key], list)]) - ) - start_index = ( - start_index - if (params and (start_index := params.get("start_index"))) - else 0 - ) - # Fixtures generated before _handle_response_lists was implemented - # could have incomplete lists. - if ( - len(result[list_key]) < result["sum"] - and self.fix_incomplete_fixture_lists - ): - result["sum"] = len(result[list_key]) - if self.warn_fixture_missing_methods: - pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] - self.fixture_name, set() - ).add(f"{method} (incomplete '{list_key}' list)") - - result[list_key] = result[list_key][ - start_index : start_index + self.list_return_size - ] - return {"result": result, "error_code": 0} + return self._get_method_from_info(method, params) if self.verbatim: return { @@ -513,13 +641,11 @@ async def _send_request(self, request_dict: dict): "method": method, } - if ( + if missing_result := self._missing_result(method): # FIXTURE_MISSING is for service calls not in place when # SMART fixtures started to be generated - missing_result := self.FIXTURE_MISSING_MAP.get(method) - ) and missing_result[0] in self.components: # Copy to info so it will work with update methods - info[method] = copy.deepcopy(missing_result[1]) + info[method] = missing_result result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} elif ( @@ -544,7 +670,7 @@ async def _send_request(self, request_dict: dict): self.fixture_name, set() ).add(method) return retval - elif method in ["set_qs_info", "fw_download"]: + elif method in ["set_qs_info", "fw_download", "play_alarm", "stop_alarm"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_dynamic_light_effect(info, params) @@ -568,9 +694,30 @@ async def _send_request(self, request_dict: dict): return self._set_on_off_gradually_info(info, params) elif method == "set_child_protection": return self._update_sysinfo_key(info, "child_protection", params["enable"]) - elif method[:4] == "set_": - target_method = f"get_{method[4:]}" + elif method == "remove_child_device_list": + return self._hub_remove_device(info, params) + # actions + elif method in [ + "begin_scanning_child_device", # hub pairing + "add_child_device_list", # hub pairing + "remove_child_device_list", # hub pairing + "playSelectAudio", # vacuum special actions + "resetConsumablesTime", # vacuum special actions + ]: + return {"error_code": 0} + elif method[:3] == "set": + target_method = f"get{method[3:]}" + # Some vacuum commands do not have a getter + if method in [ + "setRobotPause", + "setSwitchClean", + "setSwitchCharge", + "setSwitchDustCollection", + ]: + return {"error_code": 0} + info[target_method].update(params) + return {"error_code": 0} async def close(self) -> None: diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index d110e7845..faaf423ff 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -6,6 +6,7 @@ from kasa import Credentials, DeviceConfig, SmartProtocol from kasa.protocols.smartcamprotocol import SmartCamProtocol +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT, SmartCamChild from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport @@ -33,7 +34,9 @@ def __init__( *, list_return_size=10, is_child=False, + get_child_fixtures=True, verbatim=False, + components_not_included=False, ): super().__init__( config=DeviceConfig( @@ -44,20 +47,35 @@ def __init__( ), ), ) + self.fixture_name = fixture_name # When True verbatim will bypass any extra processing of missing # methods and is used to test the fixture creation itself. self.verbatim = verbatim if not is_child: self.info = copy.deepcopy(info) - self.child_protocols = FakeSmartTransport._get_child_protocols( - self.info, self.fixture_name, "getChildDeviceList" - ) + # We don't need to get the child fixtures if testing things like + # lists + if get_child_fixtures: + self.child_protocols = FakeSmartTransport._get_child_protocols( + self.info, self.fixture_name, "getChildDeviceList", self.verbatim + ) else: self.info = info - # self.child_protocols = self._get_child_protocols() + self.list_return_size = list_return_size + # Setting this flag allows tests to create dummy transports without + # full fixture info for testing specific cases like list handling etc + self.components_not_included = (components_not_included,) + if not components_not_included: + self.components = { + comp["name"]: comp["version"] + for comp in self.info["getAppComponentList"]["app_component"][ + "app_component_list" + ] + } + @property def default_port(self): """Default port for the transport.""" @@ -108,10 +126,61 @@ async def _handle_control_child(self, params: dict): @staticmethod def _get_param_set_value(info: dict, set_keys: list[str], value): + cifp = info.get(CHILD_INFO_FROM_PARENT) + for key in set_keys[:-1]: info = info[key] info[set_keys[-1]] = value + if ( + cifp + and set_keys[0] == "getDeviceInfo" + and ( + child_info_parent_key + := FakeSmartCamTransport.CHILD_INFO_SETTER_MAP.get(set_keys[-1]) + ) + ): + cifp[child_info_parent_key] = value + + CHILD_INFO_SETTER_MAP = { + "device_alias": "alias", + } + + FIXTURE_MISSING_MAP = { + "getMatterSetupInfo": ( + "matter", + { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000", + }, + ), + "getSupportChildDeviceCategory": ( + "childQuickSetup", + { + "device_category_list": [ + {"category": "ipcamera"}, + {"category": "subg.trv"}, + {"category": "subg.trigger"}, + {"category": "subg.plugswitch"}, + ] + }, + ), + "getScanChildDeviceList": ( + "childQuickSetup", + { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ], + "scan_wait_time": 55, + "scan_status": "scanning", + }, + ), + } # Setters for when there's not a simple mapping of setters to getters SETTERS = { ("system", "sys", "dev_alias"): [ @@ -136,19 +205,103 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): ], } + def _hub_remove_device(self, info, params): + """Remove hub device.""" + items_to_remove = [dev["device_id"] for dev in params["child_device_list"]] + children = info["getChildDeviceList"]["child_device_list"] + new_children = [ + dev for dev in children if dev["device_id"] not in items_to_remove + ] + info["getChildDeviceList"]["child_device_list"] = new_children + + return {"result": {}, "error_code": 0} + @staticmethod def _get_second_key(request_dict: dict[str, Any]) -> str: - assert ( - len(request_dict) == 2 - ), f"Unexpected dict {request_dict}, should be length 2" + assert len(request_dict) == 2, ( + f"Unexpected dict {request_dict}, should be length 2" + ) it = iter(request_dict) next(it, None) return next(it) + def get_child_device_queries(self, method, params): + return self._get_method_from_info(method, params) + + def _get_method_from_info(self, method, params): + result = copy.deepcopy(self.info[method]) + if "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + assert isinstance(params, dict) + module_name = next(iter(params)) + + start_index = ( + start_index + if ( + params + and module_name + and (start_index := params[module_name].get("start_index")) + ) + else 0 + ) + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + async def _send_request(self, request_dict: dict): method = request_dict["method"] info = self.info + if method == "connectAp": + if self.verbatim: + return {"error_code": -1} + return {"result": {}, "error_code": 0} + if method == "scanApList": + if method in info: + result = self._get_method_from_info(method, request_dict.get("params")) + if not self.verbatim: + scan = ( + result.get("result", {}).get("onboarding", {}).get("scan", {}) + ) + ap_list = scan.get("ap_list") + if isinstance(ap_list, list) and not any( + ap.get("ssid") == "FOOBAR" for ap in ap_list + ): + ap_list.append( + { + "ssid": "FOOBAR", + "auth": 3, + "encryption": 3, + "rssi": -40, + "bssid": "00:00:00:00:00:00", + } + ) + return result + if self.verbatim: + return {"error_code": -1} + return { + "result": { + "onboarding": { + "scan": { + "publicKey": "", + "ap_list": [ + { + "ssid": "FOOBAR", + "auth": 3, + "encryption": 3, + "rssi": -40, + "bssid": "00:00:00:00:00:00", + } + ], + } + } + }, + "error_code": 0, + } if method == "controlChild": return await self._handle_control_child( request_dict["params"]["childControl"] @@ -169,12 +322,14 @@ async def _send_request(self, request_dict: dict): section = next(iter(val)) skey_val = val[section] if not isinstance(skey_val, dict): # single level query - section_key = section - section_val = skey_val - if (get_info := info.get(get_method)) and section_key in get_info: - get_info[section_key] = section_val - else: + updates = { + k: v for k, v in val.items() if k in info.get(get_method, {}) + } + if len(updates) != len(val): + # All keys to update must already be in the getter return {"error_code": -1} + info[get_method] = {**info[get_method], **updates} + break for skey, sval in skey_val.items(): section_key = skey @@ -182,11 +337,11 @@ async def _send_request(self, request_dict: dict): if setter_keys := self.SETTERS.get((module, section, section_key)): self._get_param_set_value(info, setter_keys, section_value) elif ( - section := info.get(get_method, {}) + section_data := info.get(get_method, {}) .get(module, {}) .get(section, {}) - ) and section_key in section: - section[section_key] = section_value + ) and section_key in section_data: + section_data[section_key] = section_value else: return {"error_code": -1} break @@ -199,26 +354,57 @@ async def _send_request(self, request_dict: dict): return {**result, "error_code": 0} else: return {"error_code": -1} - elif method[:3] == "get": + elif method == "removeChildDeviceList": + return self._hub_remove_device(info, request_dict["params"]["childControl"]) + # actions + elif method in [ + "addScanChildDeviceList", + "startScanChildDevice", + "motorMoveToPreset", + "addMotorPostion", # Note: API has typo in method name + ]: + return {"result": {}, "error_code": 0} + + # smartcam child devices do not make requests for getDeviceInfo as they + # get updated from the parent's query. If this is being called from a + # child it must be because the fixture has been created directly on the + # child device with a dummy parent. In this case return the child info + # from parent that's inside the fixture. + if ( + not self.verbatim + and method == "getDeviceInfo" + and (cifp := info.get(CHILD_INFO_FROM_PARENT)) + ): + mapped = SmartCamChild._map_child_info_from_parent(cifp) + result = {"device_info": {"basic_info": mapped}} + return {"result": result, "error_code": 0} + + # These methods are handled in get_child_device_query so it can be + # patched for tests to simulate dynamic devices. + if ( + method in ("getChildDeviceList", "getChildDeviceComponentList") + and method in info + ): params = request_dict.get("params") - if method in info: - result = copy.deepcopy(info[method]) - if "start_index" in result and "sum" in result: - list_key = next( - iter([key for key in result if isinstance(result[key], list)]) - ) - start_index = ( - start_index - if (params and (start_index := params.get("start_index"))) - else 0 - ) + return self.get_child_device_queries(method, params) + + if method in info: + params = request_dict.get("params") + return self._get_method_from_info(method, params) + + if self.verbatim: + return {"error_code": -1} + + if ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + missing_result := self.FIXTURE_MISSING_MAP.get(method) + ) and missing_result[0] in self.components: + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) + return {"result": result, "error_code": 0} - result[list_key] = result[list_key][ - start_index : start_index + self.list_return_size - ] - return {"result": result, "error_code": 0} - else: - return {"error_code": -1} return {"error_code": -1} async def close(self) -> None: diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py index fc1dd1fb8..fbfe6ff80 100644 --- a/tests/fixtureinfo.py +++ b/tests/fixtureinfo.py @@ -60,11 +60,19 @@ class ComponentFilter(NamedTuple): ) ] +SUPPORTED_SMARTCAM_CHILD_DEVICES = [ + (device, "SMARTCAM.CHILD") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcam/child/*.json" + ) +] + SUPPORTED_DEVICES = ( SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES + SUPPORTED_SMART_CHILD_DEVICES + SUPPORTED_SMARTCAM_DEVICES + + SUPPORTED_SMARTCAM_CHILD_DEVICES ) @@ -77,19 +85,13 @@ def idgenerator(paramtuple: FixtureInfo): return None -def get_fixture_info() -> list[FixtureInfo]: +def get_fixture_infos() -> list[FixtureInfo]: """Return raw discovery file contents as JSON. Used for discovery tests.""" fixture_data = [] for file, protocol in SUPPORTED_DEVICES: p = Path(file) - folder = Path(__file__).parent / "fixtures" - if protocol == "SMART": - folder = folder / "smart" - if protocol == "SMART.CHILD": - folder = folder / "smart/child" - p = folder / file - - with open(p) as f: + + with open(file) as f: data = json.load(f) fixture_name = p.name @@ -99,7 +101,7 @@ def get_fixture_info() -> list[FixtureInfo]: return fixture_data -FIXTURE_DATA: list[FixtureInfo] = get_fixture_info() +FIXTURE_DATA: list[FixtureInfo] = get_fixture_infos() def filter_fixtures( @@ -145,12 +147,21 @@ def _model_startswith_match(fixture_data: FixtureInfo, starts_with: str): def _component_match( fixture_data: FixtureInfo, component_filter: str | ComponentFilter ): - if (component_nego := fixture_data.data.get("component_nego")) is None: + components = {} + if component_nego := fixture_data.data.get("component_nego"): + components = { + component["id"]: component["ver_code"] + for component in component_nego["component_list"] + } + if get_app_component_list := fixture_data.data.get("getAppComponentList"): + components = { + component["name"]: component["version"] + for component in get_app_component_list["app_component"][ + "app_component_list" + ] + } + if not components: return False - components = { - component["id"]: component["ver_code"] - for component in component_nego["component_list"] - } if isinstance(component_filter, str): return component_filter in components else: @@ -179,7 +190,7 @@ def _device_type_match(fixture_data: FixtureInfo, device_type): IotDevice._get_device_type_from_sys_info(fixture_data.data) in device_type ) - elif fixture_data.protocol == "SMARTCAM": + elif fixture_data.protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"] return SmartCamDevice._get_device_type_from_sysinfo(info) in device_type return False diff --git a/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json index e40543d6b..11cafb870 100644 --- a/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "167 lamp", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json b/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json new file mode 100644 index 000000000..9b592fbb8 --- /dev/null +++ b/tests/fixtures/iot/EP25(US)_1.0_1.0.14.json @@ -0,0 +1,80 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 50, + "voltage_mv": 125403 + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.homekit": { + "setup_info_get": { + "err_code": 0, + "setup_code": "000-00-000", + "setup_payload": "0-00://0000000000000" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "AC:15:A2:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP25(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "apple", + "oemId": "00000000000000000000000000000000", + "on_time": 495961, + "relay_state": 1, + "rssi": -37, + "status": "configured", + "sw_ver": "1.0.14 Build 240424 Rel.094105", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json index 238265a2a..5be97e874 100644 --- a/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_004F", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Zombie", - "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F200", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "Magic", - "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F201", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json index 99ecdaa57..6d15034f1 100644 --- a/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json index bb316b830..e28301d5a 100644 --- a/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json @@ -78,7 +78,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test ES20M", + "alias": "#MASKED_NAME#", "brightness": 35, "dev_name": "Wi-Fi Smart Dimmer with sensor", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json index 6e33fd7dc..324e193a7 100644 --- a/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json +++ b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json @@ -1,18 +1,21 @@ { "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "HS100(UK)", - "device_type": "IOT.SMARTPLUGSWITCH", - "factory_default": true, - "hw_ver": "4.1", - "ip": "127.0.0.123", - "mac": "CC-32-E5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS100(UK)", + "device_type": "IOT.SMARTPLUGSWITCH", + "factory_default": true, + "hw_ver": "4.1", + "ip": "127.0.0.123", + "mac": "CC-32-E5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "system": { "get_sysinfo": { diff --git a/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json index 1bbe29d4c..1f2cad626 100644 --- a/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json +++ b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json @@ -18,7 +18,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Unused 3", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json index 03dd42d57..f73d62331 100644 --- a/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json +++ b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "3D Printer", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json index e5928c3dc..ec388dd33 100644 --- a/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json +++ b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Night lite", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json index 664845f6a..a9064ac74 100644 --- a/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json @@ -18,7 +18,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Corner", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json index 819c5bdd6..cf7cb9355 100644 --- a/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json index 796910043..a84c0f49b 100644 --- a/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json +++ b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Unused 1", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json index 046a89e97..ddc61ef80 100644 --- a/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json @@ -17,12 +17,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_D310", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Garage Charger 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -30,8 +30,8 @@ "state": 0 }, { - "alias": "Garage Charger 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -46,7 +46,7 @@ "hw_ver": "1.0", "latitude_i": 0, "led_off": 0, - "longitude_i": -0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS107(US)", diff --git a/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json index 99cba2880..e75b18bc5 100644 --- a/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json +++ b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Bedroom Lamp Plug", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json b/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json index 5e285e729..cf5ac0654 100644 --- a/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json +++ b/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Home Google WiFi HS110", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json index 2fbcc65cb..31e4a5f90 100644 --- a/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json +++ b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Master Bedroom Fan", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json index fc09e6f55..44370f2ed 100644 --- a/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json +++ b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "House Fan", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json index ced3e8914..b286c53f2 100644 --- a/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json +++ b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json @@ -21,7 +21,7 @@ "get_sysinfo": { "abnormal_detect": 1, "active_mode": "none", - "alias": "Garage Light", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi 3-Way Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS210(US)_3.0_1.0.10.json b/tests/fixtures/iot/HS210(US)_3.0_1.0.10.json new file mode 100644 index 000000000..30a401e97 --- /dev/null +++ b/tests/fixtures/iot/HS210(US)_3.0_1.0.10.json @@ -0,0 +1,63 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi 3-Way Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "60:83:E7:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS210(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 6525, + "relay_state": 1, + "rssi": -31, + "status": "new", + "sw_ver": "1.0.10 Build 240122 Rel.193635", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json index eef806fb4..3826d198d 100644 --- a/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json +++ b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json @@ -28,7 +28,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living Room Dimmer Switch", + "alias": "#MASKED_NAME#", "brightness": 25, "dev_name": "Smart Wi-Fi Dimmer", "deviceId": "000000000000000000000000000000000000000", @@ -38,9 +38,9 @@ "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "icon_hash": "", - "latitude_i": 11.6210, + "latitude_i": 0, "led_off": 0, - "longitude_i": 42.2074, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS220(US)", diff --git a/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json index 61e3d84e7..d7d0a5a24 100644 --- a/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json @@ -17,7 +17,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living Room Dimmer Switch", + "alias": "#MASKED_NAME#", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json index a6d34957d..0fc22a399 100644 --- a/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json @@ -22,12 +22,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_DAE1", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Office Monitor 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -35,8 +35,8 @@ "state": 0 }, { - "alias": "Office Monitor 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -44,8 +44,8 @@ "state": 0 }, { - "alias": "Office Monitor 3", - "id": "02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -53,8 +53,8 @@ "state": 0 }, { - "alias": "Office Laptop Dock", - "id": "03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -62,8 +62,8 @@ "state": 0 }, { - "alias": "Office Desk Light", - "id": "04", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -71,8 +71,8 @@ "state": 0 }, { - "alias": "Laptop", - "id": "05", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, @@ -87,7 +87,7 @@ "hw_ver": "1.0", "latitude_i": 0, "led_off": 0, - "longitude_i": -0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS300(US)", diff --git a/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json index 388fadf35..a174027ca 100644 --- a/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json +++ b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json @@ -10,12 +10,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_2CA9", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Home CameraPC", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 1 }, { - "alias": "Home Firewalla", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -32,8 +32,8 @@ "state": 1 }, { - "alias": "Home Cox modem", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -41,8 +41,8 @@ "state": 1 }, { - "alias": "Home rpi3-2", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -50,8 +50,8 @@ "state": 1 }, { - "alias": "Home Camera Switch", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED05", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -59,8 +59,8 @@ "state": 1 }, { - "alias": "Home Network Switch", - "id": "800623145DFF1AA096363EFD161C2E661A9D8DED04", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json index bdab432e2..bca720892 100644 --- a/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json @@ -15,8 +15,8 @@ "child_num": 6, "children": [ { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -24,8 +24,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -33,8 +33,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -42,8 +42,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -51,8 +51,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D04", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -60,8 +60,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D05", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json index 3b99cf36e..8a5b22c46 100644 --- a/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json @@ -11,12 +11,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_5C33", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Plug 1", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -24,8 +24,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -33,8 +33,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -42,8 +42,8 @@ "state": 0 }, { - "alias": "Plug 4", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -51,8 +51,8 @@ "state": 0 }, { - "alias": "Plug 5", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -60,8 +60,8 @@ "state": 0 }, { - "alias": "Plug 6", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json index 94c388580..89b623bdf 100644 --- a/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bulb3", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json b/tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json new file mode 100644 index 000000000..75427f79c --- /dev/null +++ b/tests/fixtures/iot/KL110B(UN)_1.0_1.8.11.json @@ -0,0 +1,288 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "action": 2, + "err_code": 0, + "id": "CAD48466AAF086367653A7AAB3A69ED4", + "light": { + "brightness": 15, + "color_temp": 0, + "hue": 0, + "on_off": 1, + "saturation": 0 + }, + "schd_time": 69000, + "type": 1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [ + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "CAD48466AAF086367653A7AAB3A69ED4", + "name": "Schedule Rule", + "repeat": 1, + "s_light": { + "brightness": 15, + "color_temp": 0, + "hue": 0, + "mode": "customize_preset", + "on_off": 1, + "saturation": 0, + "transition_period": 5000 + }, + "sact": 2, + "smin": 1150, + "soffset": -59, + "stime_opt": 2, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "FBF05A0A587AC23BCADE2F5EEC72DE56", + "name": "Schedule Rule", + "repeat": 1, + "s_light": { + "brightness": 0, + "color_temp": 0, + "hue": 0, + "mode": "last_status", + "on_off": 0, + "saturation": 0, + "transition_period": 0 + }, + "sact": 2, + "smin": 0, + "soffset": 0, + "stime_opt": 0, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "8E3EA003DF7BC480EA09C332CD2DA06A", + "name": "Schedule Rule", + "repeat": 1, + "s_light": { + "brightness": 0, + "color_temp": 0, + "hue": 0, + "mode": "last_status", + "on_off": 0, + "saturation": 0, + "transition_period": 30000 + }, + "sact": 2, + "smin": 415, + "soffset": 0, + "stime_opt": 1, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "emin": 0, + "enable": 1, + "eoffset": 0, + "etime_opt": -1, + "id": "9FBD711FDCC696E33B9CF3E523559C64", + "name": "name", + "repeat": 1, + "s_light": { + "brightness": 1, + "color_temp": 0, + "hue": 0, + "mode": "customize_preset", + "on_off": 1, + "saturation": 0, + "transition_period": 300000 + }, + "sact": 2, + "smin": 1320, + "soffset": 0, + "stime_opt": 0, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + ], + "version": 2 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "mode": "customize_preset", + "saturation": 0 + }, + "soft_on": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "mode": "customize_preset", + "saturation": 0 + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 270, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10 + }, + "get_light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 291800, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "mic_mac": "D84732000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL110B(UN)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 15, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -65, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json index 1d8e1fce9..0bbc9886b 100644 --- a/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json @@ -19,7 +19,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Home Family Room Table", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json index c251f2fa6..50bd202ee 100644 --- a/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json @@ -34,11 +34,11 @@ }, "description": "Smart Wi-Fi LED Bulb with Tunable White Light", "dev_state": "normal", - "deviceId": "801200814AD69370AC59DE5501319C051AF409C3", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, "heapsize": 290784, - "hwId": "111E35908497A05512E259BB76801E10", + "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 0, "is_dimmable": 1, @@ -52,10 +52,10 @@ "on_off": 1, "saturation": 0 }, - "mic_mac": "D80D17150474", + "mic_mac": "D80D17000000", "mic_type": "IOT.SMARTBULB", "model": "KL120(US)", - "oemId": "1210657CD7FBDC72895644388EEFAE8B", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { "brightness": 100, diff --git a/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json index 1fca69246..aedcb1f68 100644 --- a/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json +++ b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "kasa-bc01", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json index b7fa640bf..9d19ca576 100644 --- a/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json +++ b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test bulb 6", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json b/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json index f15e3602d..ce3034629 100644 --- a/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json +++ b/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json index 3ee4cb2e7..d9eaaca16 100644 --- a/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bulb2", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json index b6670a7ae..38a8805d0 100644 --- a/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json index dc0ef45ab..be34f9c5b 100644 --- a/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "KL135 Bulb", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json b/tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json new file mode 100644 index 000000000..db4496552 --- /dev/null +++ b/tests/fixtures/iot/KL400L10(US)_1.0_1.0.10.json @@ -0,0 +1,144 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 1800, + "total_wh": 443 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.lightStrip": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 90, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 220, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 100, + "wattage": 10 + }, + "get_light_state": { + "err_code": 0, + "groups": [ + [ + 0, + 0, + 251, + 0, + 10, + 0 + ] + ], + "length": 1, + "mode": "normal", + "on_off": 1, + "transition": 500 + } + }, + "system": { + "get_sysinfo": { + "LEF": 0, + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "length": 1, + "light_state": { + "brightness": 10, + "color_temp": 0, + "hue": 251, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 10, + "custom": 0, + "enable": 0, + "id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", + "name": "Lightning" + }, + "longitude_i": 0, + "mic_mac": "D8:44:89:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L10(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 0, + "hue": 1, + "index": 0, + "mode": 1, + "saturation": 100 + }, + { + "brightness": 15, + "color_temp": 0, + "hue": 251, + "index": 1, + "mode": 1, + "saturation": 0 + } + ], + "rssi": -38, + "status": "new", + "sw_ver": "1.0.10 Build 220929 Rel.170054" + } + } +} diff --git a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.11.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.11.json new file mode 100644 index 000000000..f00951ff8 --- /dev/null +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.11.json @@ -0,0 +1,98 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 9600, + "total_wh": 1193 + } + }, + "system": { + "get_sysinfo": { + "LEF": 0, + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 1, + "light_state": { + "brightness": 70, + "color_temp": 3015, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 43, + "custom": 0, + "enable": 0, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "name": "Aurora" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 50, + "color_temp": 9500, + "hue": 0, + "index": 0, + "mode": 1, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 0, + "hue": 240, + "index": 1, + "mode": 1, + "saturation": 100 + }, + { + "brightness": 50, + "color_temp": 0, + "hue": 0, + "index": 2, + "mode": 1, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 3015, + "hue": 0, + "index": 3, + "mode": 1, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 0, + "hue": 277, + "index": 4, + "mode": 1, + "saturation": 86 + } + ], + "rssi": -56, + "status": "new", + "sw_ver": "1.0.11 Build 240809 Rel.173811" + } + } +} diff --git a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json index 64adf5555..1bcd088b7 100644 --- a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 0, "active_mode": "none", - "alias": "Kl400", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json index a737cd2a1..6a15c16c3 100644 --- a/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 0, "active_mode": "none", - "alias": "Kl400", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json index 0d19e7949..2d16adba5 100644 --- a/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "Kl420 test", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json index a956575be..8a924c197 100644 --- a/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json +++ b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "Bedroom light strip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json index 9b6d84136..5bda57627 100644 --- a/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json @@ -23,7 +23,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Bedroom Lightstrip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json index f39c55193..380250ff3 100644 --- a/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json index e69a9dc1f..c5cf550bd 100644 --- a/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "89 strip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json index d5f2eafbc..2d9f7535f 100644 --- a/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "kl430 updated", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json index f3e43c9a5..6e30c136d 100644 --- a/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json +++ b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kl50", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json index fa842b47c..22dadaee2 100644 --- a/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json +++ b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json @@ -32,7 +32,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Bulb_9179", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -60,7 +60,7 @@ "on_off": 0 }, "longitude_i": 0, - "mic_mac": "74DA88C89179", + "mic_mac": "74DA88000000", "mic_type": "IOT.SMARTBULB", "model": "KL60(UN)", "oemId": "00000000000000000000000000000000", diff --git a/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json index e52cb85c5..6834d925d 100644 --- a/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json +++ b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Gold fil", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json index fb62654b5..46e9ec4ee 100644 --- a/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json +++ b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kasa", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json index ce1943752..91e310d3c 100644 --- a/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": -7, diff --git a/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json index afb5a5fe4..fb5efac81 100644 --- a/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json +++ b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json index cb32e7c6c..2bb0d21e3 100644 --- a/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json +++ b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json index fef495d65..40a57fd5e 100644 --- a/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json +++ b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_C2D6", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "One ", - "id": "80066788DFFFD572D9F2E4A5A6847669213E039F00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Two ", - "id": "80066788DFFFD572D9F2E4A5A6847669213E039F01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json index d02d766b6..b5c6a1050 100644 --- a/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json +++ b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "Bedroom Power Strip", + "alias": "#MASKED_NAME#", "child_num": 3, "children": [ { - "alias": "Plug 1", - "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7700", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Plug 2", - "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7701", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "8006E9854025B67C3F9D99BA1E66223D1C9A8A7702", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json index 96c2f8c96..a95905579 100644 --- a/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_BDF6", + "alias": "#MASKED_NAME#", "child_num": 3, "children": [ { - "alias": "Plug 1", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json index d500ebb8f..333df3f6c 100644 --- a/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json @@ -5,8 +5,8 @@ "child_num": 3, "children": [ { - "alias": "#MASKED_NAME#", - "id": "800639AA097730E58235162FCDA301CE1F038F9101", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "800639AA097730E58235162FCDA301CE1F038F9102", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "#MASKED_NAME#", - "id": "800639AA097730E58235162FCDA301CE1F038F9100", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json index afdb7bfcd..cd09a434c 100644 --- a/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json @@ -17,12 +17,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_2ECE", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Rope", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "action": 1, "schd_sec": 69240, @@ -32,8 +32,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json index 23cd22d11..3f838a91c 100644 --- a/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json +++ b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_DC2A", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Anc ", - "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3400", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Plug 2", - "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3401", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json index e93eea8f8..ec1c37f36 100644 --- a/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json +++ b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json @@ -5,8 +5,8 @@ "child_num": 2, "children": [ { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A100", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A101", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json index 18580f4ea..5a60a4003 100644 --- a/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json +++ b/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json @@ -5,8 +5,8 @@ "child_num": 2, "children": [ { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A100", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "#MASKED_NAME#", - "id": "8006521377E30159055A751347B5A5E321A8D0A101", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json index 644c4e5f4..f3006cf49 100644 --- a/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json +++ b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kp401", + "alias": "#MASKED_NAME#", "dev_name": "Smart Outdoor Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json index ad6357f3c..806bdc27b 100644 --- a/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json @@ -15,7 +15,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Porch Lights", + "alias": "#MASKED_NAME#", "brightness": 50, "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json new file mode 100644 index 000000000..4fc94890f --- /dev/null +++ b/tests/fixtures/iot/KS200(US)_1.0_1.0.8.json @@ -0,0 +1,63 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Light Switch", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "A8:42:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -46, + "status": "new", + "sw_ver": "1.0.8 Build 240424 Rel.101842", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json index 24acdb976..f9498ae90 100644 --- a/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json index 3806895bb..719dab2ed 100644 --- a/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json @@ -66,7 +66,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test KS200M", + "alias": "#MASKED_NAME#", "dev_name": "Smart Light Switch with PIR", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json index f5c8c1dd1..debdd722e 100644 --- a/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json +++ b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json @@ -11,7 +11,7 @@ "stopConnect": 0, "tcspInfo": "", "tcspStatus": 1, - "username": "#MASKED_NAME#" + "username": "user@example.com" }, "get_intl_fw_list": { "err_code": 0, diff --git a/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json index 40da46fdd..3dceb3222 100644 --- a/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json +++ b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json @@ -78,7 +78,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Garage Entryway Lights", + "alias": "#MASKED_NAME#", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer with sensor", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json index a9e529bcc..8876a1af6 100644 --- a/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json +++ b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json @@ -14,7 +14,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test KS230", + "alias": "#MASKED_NAME#", "brightness": 60, "dc_state": 0, "dev_name": "Wi-Fi Smart 3-Way Dimmer", diff --git a/tests/fixtures/iot/KS230(US)_2.0_1.0.11.json b/tests/fixtures/iot/KS230(US)_2.0_1.0.11.json new file mode 100644 index 000000000..213f24602 --- /dev/null +++ b/tests/fixtures/iot/KS230(US)_2.0_1.0.11.json @@ -0,0 +1,112 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.dimmer": { + "get_default_behavior": { + "double_click": { + "mode": "none" + }, + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "long_press": { + "mode": "instant_on_off" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 11, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dc_state": 0, + "dev_name": "Wi-Fi Smart 3-Way Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "5C:E9:31:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS230(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -41, + "status": "new", + "sw_ver": "1.0.11 Build 240516 Rel.104458", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/LB100(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB100(US)_1.0_1.8.11.json new file mode 100644 index 000000000..b290a93b2 --- /dev/null +++ b/tests/fixtures/iot/LB100(US)_1.0_1.8.11.json @@ -0,0 +1,135 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 4400 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 50, + "lamp_beam_angle": 270, + "max_lumens": 600, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 7 + }, + "get_light_state": { + "brightness": 50, + "color_temp": 2700, + "err_code": 0, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 291960, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "light_state": { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "mic_mac": "50C7BF000000", + "mic_type": "IOT.SMARTBULB", + "model": "LB100(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2700, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -46, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json index ec49e91bf..8df62f234 100644 --- a/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Bulb_43EC", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/LB130(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB130(US)_1.0_1.8.11.json new file mode 100644 index 000000000..372c44f29 --- /dev/null +++ b/tests/fixtures/iot/LB130(US)_1.0_1.8.11.json @@ -0,0 +1,139 @@ +{ + "smartlife.iot.common.cloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": 0, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 0, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "last_status" + }, + "soft_on": { + "mode": "last_status" + } + }, + "get_light_details": { + "color_rendering_index": 80, + "err_code": 0, + "incandescent_equivalent": 60, + "lamp_beam_angle": 150, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10 + }, + "get_light_state": { + "dft_on_state": { + "brightness": 70, + "color_temp": 2732, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Color Changing", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 290676, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "dft_on_state": { + "brightness": 70, + "color_temp": 2732, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "mic_mac": "704F57000000", + "mic_type": "IOT.SMARTBULB", + "model": "LB130(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "index": 1, + "saturation": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 75 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 75 + } + ], + "rssi": -52, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json b/tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json new file mode 100644 index 000000000..2da0d5f34 --- /dev/null +++ b/tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json @@ -0,0 +1,86 @@ +{ + "emeter": { + "get_realtime": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.LAS": { + "get_current_brt": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.PIR": { + "get_config": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "err_code": -10008, + "err_msg": "Unsupported API call." + } + }, + "system": { + "get_sysinfo": { + "err_code": 0, + "system": { + "a_type": 2, + "alias": "#MASKED_NAME#", + "bind_status": false, + "c_opt": [ + 0, + 1 + ], + "camera_switch": "on", + "dev_name": "Kasa Spot, 24/7 Recording", + "deviceId": "0000000000000000000000000000000000000000", + "f_list": [ + 1, + 2 + ], + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "is_cal": 1, + "last_activity_timestamp": 0, + "latitude": 0, + "led_status": "on", + "longitude": 0, + "mac": "74:FE:CE:00:00:00", + "mic_mac": "74FECE000000", + "model": "EC60(US)", + "new_feature": [ + 2, + 3, + 4, + 5, + 7, + 9 + ], + "oemId": "00000000000000000000000000000000", + "resolution": "720P", + "rssi": -28, + "status": "new", + "stream_version": 2, + "sw_ver": "2.3.22 Build 20230731 rel.69808", + "system_time": 1690827820, + "type": "IOT.IPCAMERA", + "updating": false + } + } + } +} diff --git a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json index 559e834b2..40543d2d0 100644 --- a/tests/fixtures/serialization/deviceconfig_camera-aes-https.json +++ b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json @@ -1,10 +1,9 @@ { - "host": "127.0.0.1", - "timeout": 5, "connection_type": { "device_family": "SMART.IPCAMERA", "encryption_type": "AES", "https": true }, - "uses_http": false + "host": "127.0.0.1", + "timeout": 5 } diff --git a/tests/fixtures/serialization/deviceconfig_plug-klap.json b/tests/fixtures/serialization/deviceconfig_plug-klap.json index ef42bb2f9..f78918021 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-klap.json +++ b/tests/fixtures/serialization/deviceconfig_plug-klap.json @@ -1,11 +1,10 @@ { - "host": "127.0.0.1", - "timeout": 5, "connection_type": { "device_family": "SMART.TAPOPLUG", "encryption_type": "KLAP", "https": false, "login_version": 2 }, - "uses_http": false + "host": "127.0.0.1", + "timeout": 5 } diff --git a/tests/fixtures/serialization/deviceconfig_plug-xor.json b/tests/fixtures/serialization/deviceconfig_plug-xor.json index 78cc05a96..04e436399 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-xor.json +++ b/tests/fixtures/serialization/deviceconfig_plug-xor.json @@ -1,10 +1,9 @@ { - "host": "127.0.0.1", - "timeout": 5, "connection_type": { "device_family": "IOT.SMARTPLUGSWITCH", "encryption_type": "XOR", "https": false }, - "uses_http": false + "host": "127.0.0.1", + "timeout": 5 } diff --git a/tests/fixtures/smart/D100C(US)_1.0_1.1.3.json b/tests/fixtures/smart/D100C(US)_1.0_1.1.3.json new file mode 100644 index 000000000..25d598603 --- /dev/null +++ b/tests/fixtures/smart/D100C(US)_1.0_1.1.3.json @@ -0,0 +1,258 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "D100C(US)", + "device_type": "SMART.TAPOCHIME", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231221 Rel.154700", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "model": "D100C", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -24, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOCHIME" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1736433406 + }, + "get_device_usage": {}, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.3 Build 231221 Rel.154700", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 9, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "D100C", + "device_type": "SMART.TAPOCHIME", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json index 61e12b253..e83c6221d 100644 --- a/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "EP25(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP25(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json index 2d3e2e5ea..4aebbe0e7 100644 --- a/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "EP25(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP25(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json b/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json index 1126fad50..9eef29dc7 100644 --- a/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json @@ -379,21 +379,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "EP40M(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-09-0D-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP40M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/H100(AU)_1.0_1.5.23.json b/tests/fixtures/smart/H100(AU)_1.0_1.5.23.json new file mode 100644 index 000000000..69bad6ded --- /dev/null +++ b/tests/fixtures/smart/H100(AU)_1.0_1.5.23.json @@ -0,0 +1,513 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "chime", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(AU)", + "device_type": "SMART.TAPOHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-A2-F4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_alarm_configure": { + "duration": 300, + "type": "Connection 2", + "volume": "low" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 61, + "current_humidity_exception": 1, + "current_temp": 19.5, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -105, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1690859014, + "mac": "788CB5000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Pacific/Auckland", + "report_interval": 8, + "rssi": -57, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": true, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.12.0 Build 230512 Rel.103040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1734051318, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Pacific/Auckland", + "report_interval": 16, + "rssi": -59, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.23 Build 241106 Rel.093525", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "9C-A2-F4-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Auckland", + "rssi": -52, + "signal_level": 2, + "specs": "AU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 720, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 3, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1433 + }, + "get_device_time": { + "region": "Pacific/Auckland", + "time_diff": 720, + "timestamp": 1739230245 + }, + "get_device_usage": {}, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.5.23 Build 241106 Rel.093525", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 410, + "night_mode_type": "sunrise_sunset", + "start_time": 1252, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 3, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 3 + } + ], + "extra_info": { + "device_model": "H100", + "device_type": "SMART.TAPOHUB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json index 4d4936c6c..ba09016a3 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "H100(EU)", - "device_type": "SMART.TAPOHUB", - "factory_default": true, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json index 021309c78..4e0e5258f 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "H100(EU)", - "device_type": "SMART.TAPOHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_alarm_configure": { "duration": 10, @@ -469,6 +472,24 @@ "setup_code": "00000000000", "setup_payload": "00:0000000000000000000" }, + "get_scan_child_device_list": { + "child_device_list": [ + { + "category": "subg.trigger.temp-hmdt-sensor", + "device_id": "REDACTED_1", + "device_model": "T315", + "name": "REDACTED_1" + }, + { + "category": "subg.trigger.contact-sensor", + "device_id": "REDACTED_2", + "device_model": "T110", + "name": "REDACTED_2" + } + ], + "scan_status": "scanning", + "scan_wait_time": 28 + }, "get_support_alarm_type_list": { "alarm_type_list": [ "Doorbell Ring 1", diff --git a/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json b/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json index 639122bd0..fadb35d25 100644 --- a/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json @@ -92,21 +92,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "H100(EU)", - "device_type": "SMART.TAPOHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_alarm_configure": { "duration": 10, @@ -195,7 +198,7 @@ "ver_code": 1 } ], - "device_id": "0000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" } ], "start_index": 0, @@ -213,7 +216,7 @@ "current_humidity_exception": -34, "current_temp": 22.2, "current_temp_exception": 0, - "device_id": "0000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "fw_ver": "1.7.0 Build 230424 Rel.170332", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", diff --git a/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json index e67435a9b..f17269cc9 100644 --- a/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json +++ b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "HS200(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "74-FE-CE-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS200(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "74-FE-CE-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json b/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json index 63ec680b4..998189846 100644 --- a/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json +++ b/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json @@ -100,20 +100,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "owner": "00000000000000000000000000000000", - "device_type": "SMART.KASASWITCH", - "device_model": "HS220(US)", - "ip": "127.0.0.123", - "mac": "24-2F-D0-00-00-00", - "is_support_iot_cloud": true, - "obd_src": "tplink", - "factory_default": false, - "mgt_encrypt_schm": { - "is_support_https": false, - "encrypt_type": "AES", - "http_port": 80, - "lv": 2 + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS220(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "24-2F-D0-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" } }, "get_antitheft_rules": { diff --git a/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json b/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json index 4ef13a07d..0f24be148 100644 --- a/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/KH100(EU)_1.0_1.2.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KH100(EU)", - "device_type": "SMART.KASAHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(EU)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_alarm_configure": { "duration": 300, diff --git a/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json b/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json index 937fe36cc..53684a580 100644 --- a/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json +++ b/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KH100(EU)", - "device_type": "SMART.KASAHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(EU)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_alarm_configure": { "duration": 300, diff --git a/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json b/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json index 33e4cec68..c0eeb89b1 100644 --- a/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json +++ b/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json @@ -1,4 +1,4 @@ - { +{ "component_nego": { "component_list": [ { @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KH100(UK)", - "device_type": "SMART.KASAHUB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KH100(UK)", + "device_type": "SMART.KASAHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_alarm_configure": { "duration": 300, diff --git a/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json index c7b6ecb9d..41a34cb33 100644 --- a/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KP125M(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_current_power": { "current_power": 17 @@ -124,7 +127,7 @@ "longitude": 0, "mac": "00-00-00-00-00-00", "model": "KP125M", - "nickname": "IyNNQVNLRUROQU1FIyM=", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 5332, "overheated": false, @@ -133,7 +136,7 @@ "rssi": -62, "signal_level": 2, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", + "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": -360, "type": "SMART.KASAPLUG" }, diff --git a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json index 710febeb2..9878b65b7 100644 --- a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KP125M(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json index c94d4f2a8..60611f333 100644 --- a/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS205(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json index f9ac5af95..9f7419ec5 100644 --- a/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json @@ -76,21 +76,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS205(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-ED-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-ED-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json index e6945cb88..1f2d9d2bc 100644 --- a/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS225(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json index 798642d3e..61ead9294 100644 --- a/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS225(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.1.1.json b/tests/fixtures/smart/KS225(US)_1.0_1.1.1.json new file mode 100644 index 000000000..bb0bb6d60 --- /dev/null +++ b/tests/fixtures/smart/KS225(US)_1.0_1.1.1.json @@ -0,0 +1,304 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "brightness": 25, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.1 Build 240626 Rel.175125", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "KS225", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1739199350 + }, + "get_device_usage": { + "time_usage": { + "past30": 2189, + "past7": 705, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.1 Build 240626 Rel.175125", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 423, + "night_mode_type": "sunrise_sunset", + "start_time": 1036, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 3, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 3, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 19, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS225", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json index 2775ee7c2..15092b858 100644 --- a/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json @@ -414,21 +414,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS240(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, diff --git a/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json index 6d14f7bfc..fb6c667dd 100644 --- a/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS240(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, @@ -206,7 +209,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -267,7 +270,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" } ], "start_index": 0, @@ -279,7 +282,7 @@ "avatar": "switch_ks240", "bind_count": 1, "category": "kasa.switch.outlet.sub-fan", - "device_id": "000000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": true, "fan_sleep_mode_on": false, "fan_speed_level": 1, @@ -317,7 +320,7 @@ ], "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": false, "fade_off_time": 1, "fade_on_time": 1, diff --git a/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json b/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json index a3f28309f..4630a977c 100644 --- a/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json @@ -425,21 +425,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS240(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json b/tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json new file mode 100644 index 000000000..c4f591c65 --- /dev/null +++ b/tests/fixtures/smart/L430C(EU)_1.0_1.0.4.json @@ -0,0 +1,469 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 3 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 2 + }, + { + "id": "music_rhythm_bulb", + "ver_code": 4 + }, + { + "id": "bccp", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L430C(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "8C-86-DD-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_biorhythm_light_enable": false, + "avatar": "bulb", + "brightness": 50, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 50, + "color_temp": 2700, + "hue": 50, + "saturation": 50 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 241018 Rel.092847", + "has_set_location_info": true, + "hue": 50, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "8C-86-DD-00-00-00", + "model": "L430C", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Asia/Nicosia", + "rssi": -53, + "saturation": 50, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 120, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Asia/Nicosia", + "time_diff": 120, + "timestamp": 1768224264 + }, + "get_device_usage": { + "power_usage": { + "past30": 1719, + "past7": 494, + "today": 17 + }, + "saved_power": { + "past30": 10718141, + "past7": 0, + "today": 33862137 + }, + "time_usage": { + "past30": 1513958414, + "past7": 135451055, + "today": 1107603978 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": false + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.4 Build 241018 Rel.092847", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": { + "desired_states": { + "auto": false, + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "on": true, + "saturation": 0, + "transition_period": 1800000 + }, + "e_time": 0, + "id": "S4", + "s_time": 1768228140, + "type": 1 + }, + "get_on_off_gradually_info": { + "off_state": { + "duration": 10, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 5, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 52, + "color_temp": 3000, + "hue": 20, + "saturation": 30 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "day": 13, + "desired_states": { + "auto": false, + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "on": true, + "saturation": 0, + "transition_period": 1800000 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S1", + "mode": "repeat", + "month": 1, + "s_min": 330, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2026 + }, + { + "day": 12, + "desired_states": { + "on": false, + "transition_period": 1800000 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S2", + "mode": "repeat", + "month": 1, + "s_min": 1350, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2026 + }, + { + "day": 13, + "desired_states": { + "on": false, + "transition_period": 0 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S3", + "mode": "repeat", + "month": 1, + "s_min": 420, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2026 + }, + { + "day": 12, + "desired_states": { + "auto": false, + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "on": true, + "saturation": 0, + "transition_period": 1800000 + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S4", + "mode": "repeat", + "month": 1, + "s_min": 989, + "s_type": "sunset", + "time_offset": -30, + "week_day": 127, + "year": 2026 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 4 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "bccp", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L430C", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json b/tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json new file mode 100644 index 000000000..411ee84bc --- /dev/null +++ b/tests/fixtures/smart/L430P(EU)_1.0_1.0.9.json @@ -0,0 +1,368 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 3 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 2 + }, + { + "id": "music_rhythm_bulb", + "ver_code": 4 + }, + { + "id": "bccp", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L430P(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "8C-86-DD-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_biorhythm_light_enable": false, + "avatar": "bulb", + "brightness": 100, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.9 Build 250718 Rel.165940", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "8C-86-DD-00-00-00", + "model": "L430P", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -72, + "saturation": 100, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "signal_level": 1, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1762775418 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "time_usage": { + "past30": 1, + "past7": 1, + "today": 1 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": false + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.9 Build 250718 Rel.165940", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "" + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "bccp", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L430P", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json b/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json index a53e93bb2..f89dfc698 100644 --- a/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json +++ b/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L510B(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510B(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json b/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json index 9a51ea45b..a81222e4c 100644 --- a/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json +++ b/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L510E(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json b/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json index 055674d28..523d49925 100644 --- a/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json +++ b/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L510E(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json b/tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json new file mode 100644 index 000000000..4199077cb --- /dev/null +++ b/tests/fixtures/smart/L530B(EU)_3.0_1.1.9.json @@ -0,0 +1,480 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530B(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 10, + "color_temp": 4000, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 10, + "color_temp": 4000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.9 Build 240524 Rel.144922", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Auckland", + "rssi": -55, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 720, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Auckland", + "time_diff": 720, + "timestamp": 1739230276 + }, + "get_device_usage": { + "power_usage": { + "past30": 437, + "past7": 88, + "today": 2 + }, + "saved_power": { + "past30": 7987, + "past7": 2005, + "today": 62 + }, + "time_usage": { + "past30": 8424, + "past7": 2093, + "today": 64 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 100, + 0, + 0, + 2700 + ], + [ + 100, + 321, + 99, + 0 + ], + [ + 100, + 196, + 99, + 0 + ], + [ + 100, + 6, + 97, + 0 + ], + [ + 100, + 160, + 100, + 0 + ], + [ + 100, + 274, + 95, + 0 + ], + [ + 100, + 48, + 100, + 0 + ], + [ + 100, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "" + }, + { + "change_mode": "bln", + "change_time": 2000, + "color_status_list": [ + [ + 100, + 54, + 6, + 0 + ], + [ + 100, + 19, + 39, + 0 + ], + [ + 100, + 194, + 52, + 0 + ], + [ + 100, + 324, + 24, + 0 + ], + [ + 100, + 170, + 34, + 0 + ], + [ + 100, + 276, + 27, + 0 + ], + [ + 100, + 56, + 46, + 0 + ], + [ + 100, + 221, + 36, + 0 + ] + ], + "id": "L2", + "scene_name": "" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.9 Build 240524 Rel.144922", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "states": [ + { + "brightness": 100, + "color_temp": 4000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 50, + "color_temp": 4000, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 3, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L530", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json index 10b9d3002..05c04522f 100644 --- a/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -100,21 +100,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json index b5b90d32d..a32c0463d 100644 --- a/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json index 0e0ad2fa6..8da76d78b 100644 --- a/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-E9-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, @@ -175,7 +178,7 @@ "longitude": 0, "mac": "5C-E9-31-00-00-00", "model": "L530", - "nickname": "TGl2aW5nIFJvb20gQnVsYg==", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", diff --git a/tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json b/tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json new file mode 100644 index 000000000..145c93f42 --- /dev/null +++ b/tests/fixtures/smart/L530E(TW)_2.0_1.1.1.json @@ -0,0 +1,616 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(TW)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-62-8B-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp": 6500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "saturation": 0 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.1 Build 240623 Rel.114041", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.0", + "ip": "127.0.0.123", + "lang": "zh_TW", + "latitude": 0, + "longitude": 0, + "mac": "5C-62-8B-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Asia/Taipei", + "rssi": -44, + "saturation": 0, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 480, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Asia/Taipei", + "time_diff": 480, + "timestamp": 1738811667 + }, + "get_device_usage": { + "power_usage": { + "past30": 17, + "past7": 17, + "today": 17 + }, + "saved_power": { + "past30": 416, + "past7": 416, + "today": 416 + }, + "time_usage": { + "past30": 433, + "past7": 433, + "today": 433 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 100, + 0, + 0, + 2700 + ], + [ + 100, + 321, + 99, + 0 + ], + [ + 100, + 196, + 99, + 0 + ], + [ + 100, + 6, + 97, + 0 + ], + [ + 100, + 160, + 100, + 0 + ], + [ + 100, + 274, + 95, + 0 + ], + [ + 100, + 48, + 100, + 0 + ], + [ + 100, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "" + }, + { + "change_mode": "bln", + "change_time": 2000, + "color_status_list": [ + [ + 100, + 54, + 6, + 0 + ], + [ + 100, + 19, + 39, + 0 + ], + [ + 100, + 194, + 52, + 0 + ], + [ + 100, + 324, + 24, + 0 + ], + [ + 100, + 170, + 34, + 0 + ], + [ + 100, + 276, + 27, + 0 + ], + [ + 100, + 56, + 46, + 0 + ], + [ + 100, + 221, + 36, + 0 + ] + ], + "id": "L2", + "scene_name": "" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.1 Build 240623 Rel.114041", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "states": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 20, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L530", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json index 6dac10489..0c80d3a52 100644 --- a/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json +++ b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-62-8B-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-62-8B-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L530EA(EU)_1.0_1.4.2.json b/tests/fixtures/smart/L530EA(EU)_1.0_1.4.2.json new file mode 100644 index 000000000..4f5b382c7 --- /dev/null +++ b/tests/fixtures/smart/L530EA(EU)_1.0_1.4.2.json @@ -0,0 +1,537 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 3 + }, + { + "id": "on_off_gradually", + "ver_code": 4 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "tpap", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530EA(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "0C-EF-15-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1, + "tpap_preferred": false + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_biorhythm_light_enable": false, + "auto_biorhythm_light_id": "R0", + "avatar": "hang_lamp_1", + "brightness": 80, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 80, + "color_temp": 2700, + "hue": 0, + "saturation": 0 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "dynamic_light_effect_id": "L1", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.4.2 Build 260113 Rel.172011", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "0C-EF-15-00-00-00", + "model": "L530EA", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Brussels", + "rssi": -44, + "saturation": 0, + "signal_level": 3, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Brussels", + "time_diff": 60, + "timestamp": 1776445902 + }, + "get_device_usage": { + "power_usage": { + "past30": 16, + "past30_mwh": 16169, + "past7": 16, + "past7_mwh": 16169, + "today": 6, + "today_mwh": 6169 + }, + "saved_power": { + "past30": 96, + "past30_mwh": 95831, + "past7": 96, + "past7_mwh": 95831, + "today": 48, + "today_mwh": 47831 + }, + "time_usage": { + "past30": 112, + "past7": 112, + "today": 54 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 100, + 0, + 0, + 2700 + ], + [ + 100, + 321, + 99, + 0 + ], + [ + 100, + 196, + 99, + 0 + ], + [ + 100, + 6, + 97, + 0 + ], + [ + 100, + 160, + 100, + 0 + ], + [ + 100, + 274, + 95, + 0 + ], + [ + 100, + 48, + 100, + 0 + ], + [ + 100, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "" + }, + { + "change_mode": "bln", + "change_time": 2000, + "color_status_list": [ + [ + 100, + 54, + 6, + 0 + ], + [ + 100, + 19, + 39, + 0 + ], + [ + 100, + 194, + 52, + 0 + ], + [ + 100, + 324, + 24, + 0 + ], + [ + 100, + 170, + 34, + 0 + ], + [ + 100, + 276, + 27, + 0 + ], + [ + 100, + 56, + 46, + 0 + ], + [ + 100, + 221, + 36, + 0 + ] + ], + "id": "L2", + "scene_name": "" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": true + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.4.2 Build 260113 Rel.172011", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "change_state": { + "enable": false + }, + "off_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 70, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 70, + "color_temp": 2500, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa3_sae", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 8, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L530EA", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json b/tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json new file mode 100644 index 000000000..9e7a6a8a4 --- /dev/null +++ b/tests/fixtures/smart/L535E(EU)_3.0_1.1.8.json @@ -0,0 +1,559 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L535E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "BC-07-1D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "floor_lamp_3", + "brightness": 50, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 0 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.8 Build 240708 Rel.165207", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "BC-07-1D-00-00-00", + "model": "L535", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Paris", + "rssi": -29, + "saturation": 0, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Paris", + "time_diff": 60, + "timestamp": 1748538888 + }, + "get_device_usage": { + "power_usage": { + "past30": 43, + "past7": 43, + "today": 6 + }, + "saved_power": { + "past30": 573, + "past7": 573, + "today": 87 + }, + "time_usage": { + "past30": 616, + "past7": 616, + "today": 93 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 100, + 0, + 0, + 2700 + ], + [ + 100, + 321, + 99, + 0 + ], + [ + 100, + 196, + 99, + 0 + ], + [ + 100, + 6, + 97, + 0 + ], + [ + 100, + 160, + 100, + 0 + ], + [ + 100, + 274, + 95, + 0 + ], + [ + 100, + 48, + 100, + 0 + ], + [ + 100, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "Party" + }, + { + "change_mode": "bln", + "change_time": 2000, + "color_status_list": [ + [ + 100, + 54, + 6, + 0 + ], + [ + 100, + 19, + 39, + 0 + ], + [ + 100, + 194, + 52, + 0 + ], + [ + 100, + 324, + 24, + 0 + ], + [ + 100, + 170, + 34, + 0 + ], + [ + 100, + 276, + 27, + 0 + ], + [ + 100, + 56, + 46, + 0 + ], + [ + 100, + 221, + 36, + 0 + ] + ], + "id": "L2", + "scene_name": "Relax" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.8 Build 240708 Rel.165207", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "states": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 11, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L535", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json b/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json index 4ca91c9b4..3fb263be7 100644 --- a/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json +++ b/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L630(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-AE-30-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L630(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json index 5d05bc94b..816cf8964 100644 --- a/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json +++ b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-10(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-10(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json b/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json index 8665c8f31..5c81fd322 100644 --- a/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json +++ b/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json @@ -100,20 +100,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-10(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "54-AF-97-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-10(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "54-AF-97-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json index a281f2ec4..7c7ac420c 100644 --- a/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json b/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json index 136d3a0f3..98980a4c8 100644 --- a/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json @@ -108,21 +108,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json b/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json index a55707aeb..3315b19b6 100644 --- a/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json @@ -104,20 +104,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": true, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "1C-61-B4-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "1C-61-B4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json index 5f03b5b64..0f845bf3c 100644 --- a/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json +++ b/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -116,21 +116,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "B4-B0-24-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B4-B0-24-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json index 2ea0c69f5..95e8f969e 100644 --- a/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json @@ -112,21 +112,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json index 5463944dd..992f63999 100644 --- a/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json @@ -116,21 +116,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "34-60-F9-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "34-60-F9-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json b/tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json new file mode 100644 index 000000000..298e961eb --- /dev/null +++ b/tests/fixtures/smart/L930-5(EU)_1.0_1.2.5.json @@ -0,0 +1,528 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 3 + }, + { + "id": "music_rhythm_v2", + "ver_code": 4 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L930-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "apple", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 255, + "saturation": 68 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.5 Build 240727 Rel.102843", + "has_set_location_info": false, + "hue": 255, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "L930", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -73, + "saturation": 68, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1739740342 + }, + "get_device_usage": { + "power_usage": { + "past30": 3515, + "past7": 314, + "today": 229 + }, + "saved_power": { + "past30": 31361, + "past7": 1442, + "today": 1043 + }, + "time_usage": { + "past30": 34876, + "past7": 1756, + "today": 1272 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "000000000000000000000000000/000000000000000000/000000+00000000/00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+000000000000000000000000000000000000000000000000/0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+00000000000000000000=", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.5 Build 240727 Rel.102843", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 8, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L930", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json b/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json index de7ae2c79..c374ebc5c 100644 --- a/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json +++ b/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json @@ -124,21 +124,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L930-5(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L930-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json index 337c6f2c9..2ae738cdc 100644 --- a/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json @@ -56,18 +56,21 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P100", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "mac": "1C-3B-F3-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "mac": "1C-3B-F3-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, @@ -93,7 +96,7 @@ "hw_ver": "1.0.0", "ip": "127.0.0.123", "latitude": 0, - "location": "hallway", + "location": "#MASKED_NAME#", "longitude": 0, "mac": "1C-3B-F3-00-00-00", "model": "P100", diff --git a/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json b/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json index cdddc72e0..5347d070b 100644 --- a/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json @@ -64,20 +64,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P100", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "CC-32-E5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "CC-32-E5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, @@ -108,7 +111,7 @@ "ip": "127.0.0.123", "lang": "en_US", "latitude": 0, - "location": "bedroom", + "location": "#MASKED_NAME#", "longitude": 0, "mac": "CC-32-E5-00-00-00", "model": "P100", diff --git a/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json b/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json index 5ec333435..ab75faf5d 100644 --- a/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json @@ -64,20 +64,23 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P100", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "74-DA-88-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "74-DA-88-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P105(US)_1.0_1.2.5.json b/tests/fixtures/smart/P105(US)_1.0_1.2.5.json new file mode 100644 index 000000000..415b9a2e0 --- /dev/null +++ b/tests/fixtures/smart/P105(US)_1.0_1.2.5.json @@ -0,0 +1,354 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P105(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-BA-5F-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.5 Build 240411 Rel.143808", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "98-BA-5F-00-00-00", + "model": "P105", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "America/Vancouver", + "rssi": -51, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Vancouver", + "time_diff": -480, + "timestamp": 1767608579 + }, + "get_device_usage": { + "time_usage": { + "past30": 4113, + "past7": 2443, + "today": 3 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.5 Build 240411 Rel.143808", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 488, + "night_mode_type": "sunrise_sunset", + "start_time": 990, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 14, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P105", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P110(AU)_1.0_1.3.1.json b/tests/fixtures/smart/P110(AU)_1.0_1.3.1.json new file mode 100644 index 000000000..bfd5d7854 --- /dev/null +++ b/tests/fixtures/smart/P110(AU)_1.0_1.3.1.json @@ -0,0 +1,460 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(AU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-53-22-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.3.1 Build 240621 Rel.162048", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "9C-53-22-00-00-00", + "model": "P110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "Pacific/Auckland", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 720, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Auckland", + "time_diff": 720, + "timestamp": 1739230299 + }, + "get_device_usage": { + "power_usage": { + "past30": 11, + "past7": 2, + "today": 0 + }, + "saved_power": { + "past30": 0, + "past7": 8, + "today": 0 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 0 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 238609 + }, + "get_emeter_vgain_igain": { + "igain": 11437, + "vgain": 127146 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-02-11 12:31:41", + "month_energy": 4, + "month_runtime": 10, + "today_energy": 0, + "today_runtime": 0 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.3.1 Build 240621 Rel.162048", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 410, + "night_mode_type": "sunrise_sunset", + "start_time": 1252, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 2541 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 3, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P110", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json b/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json index 6332f259e..dd7a0360d 100644 --- a/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json @@ -72,19 +72,22 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "34-60-F9-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "34-60-F9-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json index 415e8ce67..62e580fcd 100644 --- a/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "48-22-54-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json index 339c5fb26..0c7f6e83a 100644 --- a/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json +++ b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(UK)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "48-22-54-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110(UK)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json index efb88c85e..2fea43797 100644 --- a/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110M(AU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-09-0D-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110M(AU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_off_config": { "delay_min": 120, @@ -124,19 +127,6 @@ "get_connect_cloud_state": { "status": 1 }, - "get_energy_usage": { - "today_runtime": 306, - "month_runtime": 12572, - "today_energy": 173, - "month_energy": 6110, - "local_time": "2024-11-22 21:03:25", - "electricity_charge": [ - 0, - 0, - 0 - ], - "current_power": 74116 - }, "get_current_power": { "current_power": 74 }, @@ -313,6 +303,19 @@ }, "type": "constant" }, + "get_energy_usage": { + "current_power": 74116, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-11-22 21:03:25", + "month_energy": 6110, + "month_runtime": 12572, + "today_energy": 173, + "today_runtime": 306 + }, "get_fw_download_state": { "auto_upgrade": false, "download_progress": 0, diff --git a/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json index d8453319f..81174d7b7 100644 --- a/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110M(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P110M(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json index 48cd46f2e..33d7465cc 100644 --- a/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P115(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": true, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-42-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P115(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P115(US)_1.0_1.1.3.json b/tests/fixtures/smart/P115(US)_1.0_1.1.3.json new file mode 100644 index 000000000..151f7300e --- /dev/null +++ b/tests/fixtures/smart/P115(US)_1.0_1.1.3.json @@ -0,0 +1,643 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P115(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 240523 Rel.175054", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "B0-19-21-00-00-00", + "model": "P115", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "America/Indiana/Indianapolis", + "rssi": -54, + "signal_level": 2, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Indiana/Indianapolis", + "time_diff": -300, + "timestamp": 1733673137 + }, + "get_device_usage": { + "power_usage": { + "past30": 4376, + "past7": 1879, + "today": 0 + }, + "saved_power": { + "past30": 8618, + "past7": 69, + "today": 0 + }, + "time_usage": { + "past30": 12994, + "past7": 1948, + "today": 0 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 30, + "energy_wh": 1465, + "power_mw": 0, + "voltage_mv": 122133 + }, + "get_emeter_vgain_igain": { + "igain": 11101, + "vgain": 125071 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-12-08 10:52:19", + "month_energy": 2532, + "month_runtime": 2630, + "today_energy": 0, + "today_runtime": 0 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.3 Build 240523 Rel.175054", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 476, + "night_mode_type": "sunrise_sunset", + "start_time": 1040, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1934 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 25, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P115", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json index 78e876d73..1e0cf7e2b 100644 --- a/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P125M(US)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "48-22-54-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P125M(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P135(US)_1.0_1.0.5.json b/tests/fixtures/smart/P135(US)_1.0_1.0.5.json index 9f6c3b034..f1099cc77 100644 --- a/tests/fixtures/smart/P135(US)_1.0_1.0.5.json +++ b/tests/fixtures/smart/P135(US)_1.0_1.0.5.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P135(US)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "F0-A7-31-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P135(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/P135(US)_1.0_1.2.0.json b/tests/fixtures/smart/P135(US)_1.0_1.2.0.json new file mode 100644 index 000000000..ec1930378 --- /dev/null +++ b/tests/fixtures/smart/P135(US)_1.0_1.2.0.json @@ -0,0 +1,419 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P135(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "accessory_at_low_battery": false, + "avatar": "plug", + "brightness": 100, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.0 Build 240415 Rel.171222", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "model": "P135", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 3428, + "overheat_status": "normal", + "region": "America/Los_Angeles", + "rssi": -35, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1734735856 + }, + "get_device_usage": { + "time_usage": { + "past30": 57, + "past7": 57, + "today": 57 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.0 Build 240415 Rel.171222", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 15, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P135", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P210M(US)_1.0_1.0.3.json b/tests/fixtures/smart/P210M(US)_1.0_1.0.3.json new file mode 100644 index 000000000..61ac47627 --- /dev/null +++ b/tests/fixtures/smart/P210M(US)_1.0_1.0.3.json @@ -0,0 +1,1585 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 68 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170204, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 275, + "past7": 275, + "today": 168 + }, + "saved_power": { + "past30": 3289, + "past7": 3289, + "today": 745 + }, + "time_usage": { + "past30": 3564, + "past7": 3564, + "today": 913 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 589, + "energy_wh": 249, + "power_mw": 68325, + "voltage_mv": 120254 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3788, + "slot_id": 0, + "vgain": 30382 + }, + { + "igain": 3833, + "slot_id": 1, + "vgain": 30253 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-12-20 15:13:58", + "month_energy": 275, + "month_runtime": 3564, + "today_energy": 168, + "today_runtime": 913 + }, + "get_max_power": { + "max_power": 1835 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170204, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 3564, + "past7": 3564, + "today": 913 + }, + "time_usage": { + "past30": 3564, + "past7": 3564, + "today": 913 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 119720 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3788, + "slot_id": 0, + "vgain": 30382 + }, + { + "igain": 3833, + "slot_id": 1, + "vgain": 30253 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-12-20 15:13:58", + "month_energy": 0, + "month_runtime": 3564, + "today_energy": 0, + "today_runtime": 913 + }, + "get_max_power": { + "max_power": 1827 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P210M(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "DC-62-79-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170202, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "P210M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 170202, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "America/Los_Angeles", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "DC-62-79-00-00-00", + "model": "P210M", + "nickname": "", + "oem_id": "00000000000000000000000000000000", + "region": "America/Los_Angeles", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1734736436 + }, + "get_device_usage": { + "power_usage": { + "past30": 275, + "past7": 275, + "today": 168 + }, + "saved_power": { + "past30": 3289, + "past7": 3289, + "today": 745 + }, + "time_usage": { + "past30": 3564, + "past7": 3564, + "today": 913 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3788, + "slot_id": 0, + "vgain": 30382 + }, + { + "igain": 3833, + "slot_id": 1, + "vgain": 30253 + } + ] + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.3 Build 240703 Rel.114246", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000000000-000000" + }, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 29, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P210M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json index 0d7d4a3bd..73f76e83c 100644 --- a/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.13.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P300(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, @@ -170,7 +173,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000002" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -235,7 +238,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" }, { "component_list": [ @@ -300,7 +303,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" } ], "start_index": 0, @@ -318,7 +321,7 @@ }, "type": "custom" }, - "device_id": "000000000000000000000000000000000000000002", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", @@ -347,7 +350,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", @@ -379,7 +382,7 @@ }, "type": "custom" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.13 Build 230925 Rel.150200", diff --git a/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json index dd40708e2..e9d4b54ff 100644 --- a/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json @@ -495,21 +495,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P300(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, diff --git a/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json b/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json index 17df5ac5e..eaa03a35e 100644 --- a/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P300(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "78-8C-B5-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P300(EU)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "78-8C-B5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, @@ -170,7 +173,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -235,7 +238,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000002" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" }, { "component_list": [ @@ -300,7 +303,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000003" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" } ], "start_index": 0, @@ -315,7 +318,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -329,7 +332,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 366, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 1, "region": "Europe/Berlin", @@ -344,7 +347,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000002", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -358,7 +361,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 366, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 2, "region": "Europe/Berlin", @@ -373,7 +376,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000003", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.7 Build 220715 Rel.200458", @@ -387,7 +390,7 @@ "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 366, - "original_device_id": "8022852468EC205A8178C7CBE81FC119213BC020", + "original_device_id": "0000000000000000000000000000000000000000", "overheat_status": "normal", "position": 3, "region": "Europe/Berlin", diff --git a/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json b/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json index 4e67f482c..398977ada 100644 --- a/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json +++ b/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json @@ -1385,21 +1385,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P304M(UK)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-6E-84-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P304M(UK)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": true, diff --git a/tests/fixtures/smart/P306(US)_1.0_1.1.2.json b/tests/fixtures/smart/P306(US)_1.0_1.1.2.json new file mode 100644 index 000000000..a5fcb1e8f --- /dev/null +++ b/tests/fixtures/smart/P306(US)_1.0_1.1.2.json @@ -0,0 +1,1708 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "usb", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": true, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169807, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 4, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169808, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_3": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169808, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_4": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169808, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_5": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bedside_lamp_1", + "bind_count": 1, + "brightness": 100, + "category": "plug.powerstrip.sub-bulb", + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 7169, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 5, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOBULB" + }, + "get_device_usage": { + "time_usage": { + "past30": 2425, + "past7": 2425, + "today": 758 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P306(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_light_control", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + } + ], + "start_index": 0, + "sum": 5 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "usb", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": true, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 4, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 169805, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "bedside_lamp_1", + "bind_count": 1, + "brightness": 100, + "category": "plug.powerstrip.sub-bulb", + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P306", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 7166, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 5, + "region": "America/Los_Angeles", + "slot_number": 5, + "status_follow_edge": true, + "type": "SMART.TAPOBULB" + } + ], + "start_index": 0, + "sum": 5 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "model": "P306", + "nickname": "", + "oem_id": "00000000000000000000000000000000", + "region": "America/Los_Angeles", + "rssi": -46, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1734736024 + }, + "get_device_usage": { + "time_usage": { + "past30": 3561, + "past7": 3561, + "today": 907 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_homekit_info": { + "mfi_setup_code": "000-00-000", + "mfi_setup_id": "0000", + "mfi_token_token": "000000000000000000000000000000000000000000000000/00000000000000000000000000000000000000000000000000000000/0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/0000000000000000000", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.2 Build 240531 Rel.204226", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 24, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P306", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/P316M(US)_1.6_1.0.5.json b/tests/fixtures/smart/P316M(US)_1.6_1.0.5.json new file mode 100644 index 000000000..98f33d3d0 --- /dev/null +++ b/tests/fixtures/smart/P316M(US)_1.6_1.0.5.json @@ -0,0 +1,3485 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyAx", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2255, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 1, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120209 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:43", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1644 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyAy", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2256, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 2, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120118 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:44", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1643 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_3": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 38 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyAz", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2256, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 3, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 23, + "past7": 23, + "today": 23 + }, + "saved_power": { + "past30": 13, + "past7": 13, + "today": 13 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 335, + "energy_wh": 23, + "power_mw": 38617, + "voltage_mv": 119299 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:44", + "month_energy": 23, + "month_runtime": 36, + "today_energy": 23, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1633 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_4": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 1 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyA0", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2257, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 4, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 85, + "energy_wh": 0, + "power_mw": 1999, + "voltage_mv": 119801 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:45", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1638 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_5": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyA1", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2257, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 5, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120047 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:45", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1642 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_6": { + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "2379878FD20427DA56CDE7809167A79E", + "hw_ver": "1.6", + "is_usb": false, + "latitude": -1879048193, + "longitude": -1879048193, + "mac": "A829481CEB5D", + "model": "P316M", + "nickname": "U21hcnQgUGx1ZyA2", + "oem_id": "B956FCA0B4CAEE8BE9E3E923D29DC8A0", + "on_off": 1, + "on_time": 2257, + "original_device_id": "8022F9F21DB6889D90BF8E7AAB04CD47242AAAF9", + "overcurrent_status": "normal", + "position": 6, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 36, + "past7": 36, + "today": 36 + }, + "time_usage": { + "past30": 36, + "past7": 36, + "today": 36 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 119868 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:45", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_max_power": { + "max_power": 1639 + }, + "get_next_event": {}, + "get_protection_power": { + "protection_enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + } + } + }, + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P316M(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-29-48-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "charging_protection", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6" + } + ], + "start_index": 0, + "sum": 6 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 1, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 2, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 3, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 4, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 5, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "charging_status": "normal", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A82948000000", + "model": "P316M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 2253, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "position": 6, + "power_protection_status": "normal", + "region": "America/New_York", + "slot_number": 6, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 6 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-29-48-00-00-00", + "model": "P316M", + "nickname": "", + "oem_id": "00000000000000000000000000000000", + "region": "America/New_York", + "rssi": -47, + "signal_level": 3, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1753309182 + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 0, + "power_mw": 0, + "voltage_mv": 120209 + }, + "get_emeter_vgain_igain": { + "data": [ + { + "igain": 3821, + "slot_id": 0, + "vgain": 30247 + }, + { + "igain": 3821, + "slot_id": 1, + "vgain": 30224 + }, + { + "igain": 3821, + "slot_id": 2, + "vgain": 29991 + }, + { + "igain": 3803, + "slot_id": 3, + "vgain": 30096 + }, + { + "igain": 3779, + "slot_id": 4, + "vgain": 30203 + }, + { + "igain": 3852, + "slot_id": 5, + "vgain": 30158 + } + ] + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2025-07-23 18:19:43", + "month_energy": 0, + "month_runtime": 36, + "today_energy": 0, + "today_runtime": 36 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.5 Build 250306 Rel.151943", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_max_power": { + "max_power": 1644 + }, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 21, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P316M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json new file mode 100644 index 000000000..5a09c155f --- /dev/null +++ b/tests/fixtures/smart/RV20 Max Plus(EU)_1.0_1.0.7.json @@ -0,0 +1,382 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "clean", + "ver_code": 3 + }, + { + "id": "battery", + "ver_code": 1 + }, + { + "id": "consumables", + "ver_code": 2 + }, + { + "id": "direction_control", + "ver_code": 1 + }, + { + "id": "button_and_led", + "ver_code": 1 + }, + { + "id": "speaker", + "ver_code": 3 + }, + { + "id": "schedule", + "ver_code": 3 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "map", + "ver_code": 2 + }, + { + "id": "auto_change_map", + "ver_code": -1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "dust_bucket", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "mop", + "ver_code": 1 + }, + { + "id": "do_not_disturb", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "charge_pose_clean", + "ver_code": 1 + }, + { + "id": "continue_breakpoint_sweep", + "ver_code": 1 + }, + { + "id": "goto_point", + "ver_code": 1 + }, + { + "id": "furniture", + "ver_code": 1 + }, + { + "id": "map_cloud_backup", + "ver_code": 1 + }, + { + "id": "dev_log", + "ver_code": 1 + }, + { + "id": "map_lock", + "ver_code": 1 + }, + { + "id": "carpet_area", + "ver_code": 1 + }, + { + "id": "clean_angle", + "ver_code": 1 + }, + { + "id": "clean_percent", + "ver_code": 1 + }, + { + "id": "no_pose_config", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "RV20 Max Plus(EU)", + "device_type": "SMART.TAPOROBOVAC", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 4433, + "is_support_https": true + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "getAreaUnit": { + "area_unit": 0 + }, + "getAutoChangeMap": { + "auto_change_map": false + }, + "getAutoDustCollection": { + "auto_dust_collection": 1 + }, + "getBatteryInfo": { + "battery_percentage": 75 + }, + "getCarpetClean": { + "carpet_clean_prefer": "boost" + }, + "getChildLockInfo": { + "child_lock_status": false + }, + "getCleanAttr": { + "cistern": 2, + "clean_number": 1, + "suction": 2 + }, + "getCleanInfo": { + "clean_area": 5, + "clean_percent": 1, + "clean_time": 5 + }, + "getCleanRecords": { + "lastest_day_record": [ + 1736797545, + 25, + 16, + 1 + ], + "record_list": [ + { + "clean_area": 17, + "clean_time": 27, + "dust_collection": false, + "error": 0, + "info_num": 1, + "map_id": 1736598799, + "message": 1, + "record_index": 0, + "start_type": 1, + "task_type": 0, + "timestamp": 1736601522 + }, + { + "clean_area": 14, + "clean_time": 25, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1736598799, + "message": 0, + "record_index": 1, + "start_type": 1, + "task_type": 0, + "timestamp": 1736684961 + }, + { + "clean_area": 16, + "clean_time": 25, + "dust_collection": true, + "error": 0, + "info_num": 3, + "map_id": 1736598799, + "message": 0, + "record_index": 2, + "start_type": 1, + "task_type": 0, + "timestamp": 1736797545 + } + ], + "record_list_num": 3, + "total_area": 47, + "total_number": 3, + "total_time": 77 + }, + "getCleanStatus": { + "getCleanStatus": { + "clean_status": 0, + "is_mapping": false, + "is_relocating": false, + "is_working": false + } + }, + "getConsumablesInfo": { + "charge_contact_time": 0, + "edge_brush_time": 0, + "filter_time": 0, + "main_brush_lid_time": 0, + "rag_time": 0, + "roll_brush_time": 0, + "sensor_time": 0 + }, + "getCurrentVoiceLanguage": { + "name": "2", + "version": 1 + }, + "getDoNotDisturb": { + "do_not_disturb": true, + "e_min": 480, + "s_min": 1320 + }, + "getDustCollectionInfo": { + "auto_dust_collection": true, + "dust_collection_mode": 0 + }, + "getMapInfo": { + "auto_change_map": false, + "current_map_id": 0, + "map_list": [], + "map_num": 0, + "version": "LDS" + }, + "getMopState": { + "mop_state": false + }, + "getVacStatus": { + "err_status": [ + 0 + ], + "errorCode_id": [ + 0 + ], + "prompt": [], + "promptCode_id": [], + "status": 5 + }, + "getVolume": { + "volume": 84 + }, + "get_device_info": { + "auto_pack_ver": "0.0.1.1771", + "avatar": "", + "board_sn": "000000000000", + "custom_sn": "000000000000", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240828 Rel.205951", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "linux_ver": "V21.198.1708420747", + "location": "", + "longitude": 0, + "mac": "B0-19-21-00-00-00", + "mcu_ver": "1.1.2563.5", + "model": "RV20 Max Plus", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -59, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "sub_ver": "0.0.1.1771-1.1.34", + "time_diff": 60, + "total_ver": "1.1.34", + "type": "SMART.TAPOROBOVAC" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1736598518 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 1, + "wep_supported": true + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "RV20 Max Plus", + "device_type": "SMART.TAPOROBOVAC" + } + } +} diff --git a/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json b/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json new file mode 100644 index 000000000..9b6484da8 --- /dev/null +++ b/tests/fixtures/smart/RV30 Max(US)_1.0_1.2.0.json @@ -0,0 +1,888 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "clean", + "ver_code": 3 + }, + { + "id": "battery", + "ver_code": 1 + }, + { + "id": "consumables", + "ver_code": 2 + }, + { + "id": "direction_control", + "ver_code": 1 + }, + { + "id": "button_and_led", + "ver_code": 1 + }, + { + "id": "speaker", + "ver_code": 3 + }, + { + "id": "schedule", + "ver_code": 3 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "map", + "ver_code": 2 + }, + { + "id": "auto_change_map", + "ver_code": 2 + }, + { + "id": "mop", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "do_not_disturb", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "charge_pose_clean", + "ver_code": 1 + }, + { + "id": "continue_breakpoint_sweep", + "ver_code": 1 + }, + { + "id": "goto_point", + "ver_code": 1 + }, + { + "id": "furniture", + "ver_code": 1 + }, + { + "id": "map_cloud_backup", + "ver_code": 1 + }, + { + "id": "dev_log", + "ver_code": 1 + }, + { + "id": "map_lock", + "ver_code": 1 + }, + { + "id": "carpet_area", + "ver_code": 1 + }, + { + "id": "clean_angle", + "ver_code": 1 + }, + { + "id": "clean_percent", + "ver_code": 1 + }, + { + "id": "no_pose_config", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "RV30 Max(US)", + "device_type": "SMART.TAPOROBOVAC", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "7C-F1-7E-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 4433, + "is_support_https": true + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "getAreaUnit": { + "area_unit": 1 + }, + "getAutoChangeMap": { + "auto_change_map": true + }, + "getBatteryInfo": { + "battery_percentage": 100 + }, + "getCarpetClean": { + "carpet_clean_prefer": "boost" + }, + "getChildLockInfo": { + "child_lock_status": false + }, + "getCleanAttr": { + "cistern": 1, + "clean_number": 1, + "suction": 2 + }, + "getCleanInfo": { + "clean_area": 59, + "clean_percent": 100, + "clean_time": 56 + }, + "getCleanRecords": { + "lastest_day_record": [ + 1737387294, + 56, + 59, + 1 + ], + "record_list": [ + { + "clean_area": 59, + "clean_time": 57, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 0, + "start_type": 4, + "task_type": 0, + "timestamp": 1737041654 + }, + { + "clean_area": 39, + "clean_time": 58, + "dust_collection": false, + "error": 0, + "info_num": 1, + "map_id": 1736541042, + "message": 0, + "record_index": 1, + "start_type": 1, + "task_type": 0, + "timestamp": 1737055944 + }, + { + "clean_area": 1, + "clean_time": 3, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 2, + "start_type": 1, + "task_type": 4, + "timestamp": 1737074472 + }, + { + "clean_area": 59, + "clean_time": 58, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 3, + "start_type": 4, + "task_type": 0, + "timestamp": 1737128195 + }, + { + "clean_area": 68, + "clean_time": 78, + "dust_collection": false, + "error": 0, + "info_num": 2, + "map_id": 1736541042, + "message": 0, + "record_index": 4, + "start_type": 1, + "task_type": 1, + "timestamp": 1737216716 + }, + { + "clean_area": 3, + "clean_time": 3, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734742958, + "message": 0, + "record_index": 5, + "start_type": 1, + "task_type": 3, + "timestamp": 1737300731 + }, + { + "clean_area": 20, + "clean_time": 16, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734742958, + "message": 0, + "record_index": 6, + "start_type": 1, + "task_type": 3, + "timestamp": 1737304391 + }, + { + "clean_area": 59, + "clean_time": 56, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 7, + "start_type": 4, + "task_type": 0, + "timestamp": 1737387294 + }, + { + "clean_area": 17, + "clean_time": 16, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 8, + "start_type": 1, + "task_type": 3, + "timestamp": 1736707487 + }, + { + "clean_area": 8, + "clean_time": 10, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 9, + "start_type": 1, + "task_type": 4, + "timestamp": 1736708425 + }, + { + "clean_area": 59, + "clean_time": 54, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 10, + "start_type": 4, + "task_type": 0, + "timestamp": 1736782261 + }, + { + "clean_area": 60, + "clean_time": 56, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 11, + "start_type": 4, + "task_type": 0, + "timestamp": 1736868752 + }, + { + "clean_area": 58, + "clean_time": 68, + "dust_collection": true, + "error": 1, + "info_num": 0, + "map_id": 1736541042, + "message": 0, + "record_index": 12, + "start_type": 1, + "task_type": 1, + "timestamp": 1736881428 + }, + { + "clean_area": 59, + "clean_time": 59, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 13, + "start_type": 4, + "task_type": 0, + "timestamp": 1736955682 + }, + { + "clean_area": 36, + "clean_time": 33, + "dust_collection": false, + "error": 0, + "info_num": 0, + "map_id": 1734727686, + "message": 0, + "record_index": 14, + "start_type": 1, + "task_type": 4, + "timestamp": 1736960713 + } + ], + "record_list_num": 15, + "total_area": 2304, + "total_number": 85, + "total_time": 2510 + }, + "getCleanStatus": { + "clean_status": 0, + "is_mapping": false, + "is_relocating": false, + "is_working": false + }, + "getConsumablesInfo": { + "charge_contact_time": 660, + "edge_brush_time": 2743, + "filter_time": 287, + "main_brush_lid_time": 2462, + "rag_time": 0, + "roll_brush_time": 2719, + "sensor_time": 935 + }, + "getCurrentVoiceLanguage": { + "name": "bb053ca2c5605a55090fcdb952f3902b", + "version": 2 + }, + "getDoNotDisturb": { + "do_not_disturb": true, + "e_min": 480, + "s_min": 1320 + }, + "getMapData": { + "area_list": [ + { + "cistern": 1, + "clean_number": 1, + "color": 3, + "floor_texture": -1, + "id": 5, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 4, + "floor_texture": -1, + "id": 6, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 1, + "floor_texture": 0, + "id": 2, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 5, + "floor_texture": 90, + "id": 3, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "cistern": 1, + "clean_number": 1, + "color": 2, + "floor_texture": -1, + "id": 4, + "name": "I01BU0tFRF9OQU1FIw==", + "suction": 2, + "type": "room" + }, + { + "id": 401, + "type": "virtual_wall", + "vertexs": [ + [ + 4711, + 985 + ], + [ + 4717, + -404 + ] + ] + }, + { + "id": 301, + "type": "forbid", + "vertexs": [ + [ + 3061, + -3027 + ], + [ + 3580, + -3027 + ], + [ + 3580, + -3692 + ], + [ + 3061, + -3692 + ] + ] + }, + { + "id": 402, + "type": "virtual_wall", + "vertexs": [ + [ + 5302, + 6816 + ], + [ + 5304, + 4924 + ] + ] + }, + { + "cistern": -1, + "clean_number": 1, + "id": 501, + "suction": -1, + "type": "area", + "vertexs": [ + [ + 2889, + 6241 + ], + [ + 3721, + 6241 + ], + [ + 3721, + 4919 + ], + [ + 2889, + 4919 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 101, + "type": "carpet_rectangle", + "vertexs": [ + [ + 20, + -2012 + ], + [ + 2857, + -2012 + ], + [ + 2857, + -4122 + ], + [ + 20, + -4122 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 102, + "type": "carpet_rectangle", + "vertexs": [ + [ + 1327, + 3064 + ], + [ + 2428, + 3064 + ], + [ + 2428, + 2258 + ], + [ + 1327, + 2258 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 103, + "type": "carpet_rectangle", + "vertexs": [ + [ + 4458, + 5974 + ], + [ + 5336, + 5974 + ], + [ + 5336, + 4903 + ], + [ + 4458, + 4903 + ] + ] + }, + { + "carpet_strategy": 11, + "id": 104, + "type": "carpet_rectangle", + "vertexs": [ + [ + -1383, + 2730 + ], + [ + -761, + 2730 + ], + [ + -761, + 1587 + ], + [ + -1383, + 1587 + ] + ] + } + ], + "auto_area_flag": true, + "bit_list": { + "auto_area": [ + 0, + 100 + ], + "barrier": 0, + "clean": 255, + "none": 127 + }, + "bitnum": 8, + "charge_coor": [ + 65, + 134, + 272 + ], + "furniture_list": [], + "height": 303, + "map_data": "#SCRUBBED_MAPDATA#", + "map_hash": "A5D8FA4487CC40312EF58D8123F0A4CC", + "map_id": 1734727686, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "origin_coor": [ + -33, + -108, + 270 + ], + "path_id": 122, + "pix_len": 66660, + "pix_lz4len": 6826, + "real_charge_coor": [ + 1599, + 1295, + 272 + ], + "real_origin_coor": [ + -1674, + -5424, + 270 + ], + "real_vac_coor": [ + 1599, + 1076, + 272 + ], + "resolution": 50, + "resolution_unit": "mm", + "vac_coor": [ + 65, + 130, + 272 + ], + "version": "LDS", + "width": 220 + }, + "getMapInfo": { + "auto_change_map": true, + "current_map_id": 1734727686, + "map_list": [ + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1734727686, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 270, + "update_time": 1737387285 + }, + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1734742958, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 0, + "update_time": 1737304392 + }, + { + "auto_area_flag": true, + "global_cleaned": -1, + "is_saved": true, + "map_id": 1736541042, + "map_locked": 0, + "map_name": "I01BU0tFRF9OQU1FIw==", + "rotate_angle": 270, + "update_time": 1737216718 + } + ], + "map_num": 3, + "version": "LDS" + }, + "getMopState": { + "mop_state": false + }, + "getVacStatus": { + "err_status": [ + 0 + ], + "errorCode_id": [ + 1144500830 + ], + "prompt": [], + "promptCode_id": [], + "status": 6 + }, + "getVolume": { + "volume": 60 + }, + "get_device_info": { + "auto_pack_ver": "0.0.131.1852", + "avatar": "", + "board_sn": "000000000000", + "cd": "I01BU0tFRF9CSU5BUlkj", + "custom_sn": "000000000000", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.0 Build 241219 Rel.163928", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "linux_ver": "V21.198.1708420747", + "location": "", + "longitude": 0, + "mac": "7C-F1-7E-00-00-00", + "mcu_ver": "1.1.2724.442", + "model": "RV30 Max", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "product_id": "1794", + "region": "America/Chicago", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "sub_ver": "0.0.131.1852-1.4.40", + "time_diff": -360, + "total_ver": "1.4.40", + "type": "SMART.TAPOROBOVAC" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1737399953 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": { + "inherit_status": true + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.0 Build 241219 Rel.163928", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "alarm_min": 0, + "cancel": false, + "clean_attr": { + "cistern": 2, + "clean_mode": 0, + "clean_number": 1, + "clean_order": false, + "suction": 2 + }, + "day": 21, + "enable": true, + "id": "S1", + "invalid": 0, + "mode": "repeat", + "month": 1, + "s_min": 515, + "start_remind": true, + "week_day": 62, + "year": 2025 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 1 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 5, + "wep_supported": true + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "RV30 Max", + "device_type": "SMART.TAPOROBOVAC" + } + } +} diff --git a/tests/fixtures/smart/S500(US)_1.0_1.2.0.json b/tests/fixtures/smart/S500(US)_1.0_1.2.0.json new file mode 100644 index 000000000..c3ef8b911 --- /dev/null +++ b/tests/fixtures/smart/S500(US)_1.0_1.2.0.json @@ -0,0 +1,363 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S500(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-03-8E-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "switch_s500", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.0 Build 230906 Rel.140001", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "98-03-8E-00-00-00", + "model": "S500", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "America/New_York", + "rssi": -41, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1755999484 + }, + "get_device_usage": { + "time_usage": { + "past30": 63, + "past7": 63, + "today": 63 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.0 Build 230906 Rel.140001", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 418, + "night_mode_type": "sunrise_sunset", + "start_time": 1190, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 15, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "S500", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json b/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json index a141e7003..3e6ec48df 100644 --- a/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json +++ b/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json @@ -92,21 +92,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "S500D(US)", - "device_type": "SMART.TAPOSWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "48-22-54-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S500D(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/S505(US)_1.0_1.0.2.json b/tests/fixtures/smart/S505(US)_1.0_1.0.2.json index c9c63cd7f..340bd3a1e 100644 --- a/tests/fixtures/smart/S505(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/S505(US)_1.0_1.0.2.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "S505(US)", - "device_type": "SMART.TAPOSWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json b/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json index 6adac9865..0c990d758 100644 --- a/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json @@ -100,21 +100,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "S505D(US)", - "device_type": "SMART.TAPOSWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "48-22-54-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "matter", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505D(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "matter", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/S515D(US)_1.6_1.0.4.json b/tests/fixtures/smart/S515D(US)_1.6_1.0.4.json new file mode 100644 index 000000000..c36f0c25f --- /dev/null +++ b/tests/fixtures/smart/S515D(US)_1.6_1.0.4.json @@ -0,0 +1,688 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "dimmer_custom_action", + "ver_code": 1 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + }, + { + "id": "satellite_check", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S515D(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "CC-BA-BD-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch", + "brightness": 34, + "dc_state": 0, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 240805 Rel.204647", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.6", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "CC-BA-BD-00-00-00", + "model": "S515D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "region": "America/Los_Angeles", + "rssi": -49, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -480, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/Los_Angeles", + "time_diff": -480, + "timestamp": 1771710913 + }, + "get_device_usage": { + "power_usage": { + "past30": 44, + "past7": 31, + "today": 1 + }, + "saved_power": { + "past30": 266, + "past7": 179, + "today": 0 + }, + "time_usage": { + "past30": 310, + "past7": 210, + "today": 1 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_emeter_data": { + "current_ma": 0, + "energy_wh": 44, + "power_mw": 0, + "voltage_mv": 117232 + }, + "get_emeter_vgain_igain": { + "igain": 3698, + "vgain": 122959 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2026-02-21 13:55:15", + "month_energy": 44, + "month_runtime": 310, + "today_energy": 1, + "today_runtime": 1 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.4 Build 240805 Rel.204647", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 26, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "satellite_check", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "S515D", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json b/tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json new file mode 100644 index 000000000..5892e12b4 --- /dev/null +++ b/tests/fixtures/smart/TP10(IT)_1.0_1.2.5.json @@ -0,0 +1,346 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP10(IT)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.5 Build 240411 Rel.143808", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "it_IT", + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "model": "TP10", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Europe/Rome", + "rssi": -54, + "signal_level": 2, + "specs": "IT", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Rome", + "time_diff": 60, + "timestamp": 1747840143 + }, + "get_device_usage": { + "time_usage": { + "past30": 32, + "past7": 32, + "today": 32 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.5 Build 240411 Rel.143808", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 13, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "TP10", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json b/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json index 404bfe2fc..8d0964b36 100644 --- a/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json +++ b/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json @@ -76,21 +76,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "TP15(US)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "5C-62-8B-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP15(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-62-8B-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json b/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json index 1e3321f8f..b91654149 100644 --- a/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "TP25(US)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "3C-52-A1-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TP25(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_auto_update_info": { "enable": false, @@ -170,7 +173,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000000" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" }, { "component_list": [ @@ -235,7 +238,7 @@ "ver_code": 1 } ], - "device_id": "000000000000000000000000000000000000000001" + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" } ], "start_index": 0, @@ -250,7 +253,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000000", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.2 Build 230206 Rel.095245", @@ -279,7 +282,7 @@ "default_states": { "type": "last_states" }, - "device_id": "000000000000000000000000000000000000000001", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.2 Build 230206 Rel.095245", diff --git a/tests/fixtures/smart/TS15(US)_1.0_1.2.2.json b/tests/fixtures/smart/TS15(US)_1.0_1.2.2.json new file mode 100644 index 000000000..80daaa40d --- /dev/null +++ b/tests/fixtures/smart/TS15(US)_1.0_1.2.2.json @@ -0,0 +1,336 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "TS15(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 20, + "enable": true + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "hang_lamp_1", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.2 Build 240604 Rel.122252", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "B0-19-21-00-00-00", + "model": "TS15", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Chicago", + "rssi": -38, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1768406162 + }, + "get_device_usage": { + "time_usage": { + "past30": 4024, + "past7": 759, + "today": 37 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.2.2 Build 240604 Rel.122252", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 470, + "night_mode_type": "sunrise_sunset", + "start_time": 1017, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 9, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "TS15", + "device_type": "SMART.TAPOSWITCH", + "is_klap": true + } + } +} diff --git a/tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json b/tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json new file mode 100644 index 000000000..201612cd7 --- /dev/null +++ b/tests/fixtures/smart/child/S210(EU)_1.0_1.9.0.json @@ -0,0 +1,168 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s210", + "battery_percentage": 100, + "bind_count": 2, + "category": "subg.plugswitch.switch", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fw_ver": "1.9.0 Build 231106 Rel.164425", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_low": false, + "jamming_rssi": -111, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1733332893, + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "DC6279000000", + "model": "S210", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "position": 1, + "region": "Europe/London", + "rssi": -34, + "signal_level": 3, + "slot_number": 1, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 12634, + "past7": 4388, + "today": 17 + } + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.9.0 Build 231106 Rel.164425", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_trigger_logs": { + "logs": [ + { + "event": "singleClick", + "eventId": "85caedf6-73b1-50a8-5cae-df673b150a85", + "id": 20079, + "params": { + "on_off": false + }, + "timestamp": 1735898135 + } + ], + "start_id": 20079, + "sum": 1 + } +} diff --git a/tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json b/tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json new file mode 100644 index 000000000..ee8e63e6d --- /dev/null +++ b/tests/fixtures/smart/child/S220(EU)_1.0_1.9.0.json @@ -0,0 +1,158 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch", + "battery_percentage": 100, + "bind_count": 2, + "category": "subg.plugswitch.switch", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "device_on": false, + "fw_ver": "1.9.0 Build 231106 Rel.164353", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_low": false, + "jamming_rssi": -103, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1733332989, + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "D84489000000", + "model": "S220", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "position": 1, + "region": "Europe/London", + "rssi": -42, + "signal_level": 3, + "slot_number": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 1124, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.9.0 Build 231106 Rel.164353", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + } +} diff --git a/tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json b/tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json new file mode 100644 index 000000000..e5d7915e2 --- /dev/null +++ b/tests/fixtures/smart/child/T100(US)_1.0_1.12.0.json @@ -0,0 +1,141 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": true, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.12.0 Build 230512 Rel.103040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1734051318, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Pacific/Auckland", + "report_interval": 16, + "rssi": -59, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 230512 Rel.103040", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_trigger_logs": { + "logs": [ + { + "event": "motion", + "eventId": "51281c8e-c763-3914-0281-c8ec76339140", + "id": 24, + "timestamp": 1739230242 + }, + { + "event": "motion", + "eventId": "120180c0-e874-b251-2018-0c0e874b2512", + "id": 23, + "timestamp": 1739230209 + }, + { + "event": "motion", + "eventId": "752388d5-7ba4-c378-adc7-72a845b3c875", + "id": 22, + "timestamp": 1739230188 + }, + { + "event": "motion", + "eventId": "efa20c53-74e7-264e-fa20-c5374e7264ef", + "id": 21, + "timestamp": 1739230153 + }, + { + "event": "motion", + "eventId": "962d70de-0962-df09-62d7-0de0962df096", + "id": 20, + "timestamp": 1739230137 + } + ], + "start_id": 24, + "sum": 24 + } +} diff --git a/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json b/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json index d48875e5f..0d9108eef 100644 --- a/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json +++ b/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json @@ -1,5 +1,5 @@ { - "component_nego" : { + "component_nego": { "component_list": [ { "id": "device", diff --git a/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json index bdc4eef69..c06ff49f1 100644 --- a/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json +++ b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json @@ -75,7 +75,6 @@ } ] }, - "get_auto_update_info": -1001, "get_connect_cloud_state": { "status": 0 }, @@ -84,25 +83,25 @@ "avatar": "sensor_t310", "bind_count": 1, "category": "subg.trigger.temp-hmdt-sensor", - "current_humidity": 49, - "current_humidity_exception": 0, - "current_temp": 21.7, + "current_humidity": 61, + "current_humidity_exception": 1, + "current_temp": 21.0, "current_temp_exception": 0, "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", - "fw_ver": "1.5.0 Build 230105 Rel.180832", + "fw_ver": "1.5.0", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -111, - "jamming_signal_level": 1, - "lastOnboardingTimestamp": 1724637745, - "mac": "F0A731000000", + "jamming_rssi": -108, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1690859014, + "mac": "788CB5000000", "model": "T310", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "parent_device_id": "0000000000000000000000000000000000000000", - "region": "Australia/Canberra", - "report_interval": 16, - "rssi": -46, + "region": "Pacific/Auckland", + "report_interval": 8, + "rssi": -56, "signal_level": 3, "specs": "US", "status": "online", @@ -110,8 +109,6 @@ "temp_unit": "celsius", "type": "SMART.TAPOSENSOR" }, - "get_device_time": -1001, - "get_device_usage": -1001, "get_fw_download_state": { "cloud_cache_seconds": 1, "download_progress": 0, @@ -121,7 +118,7 @@ }, "get_latest_fw": { "fw_size": 0, - "fw_ver": "1.5.0 Build 230105 Rel.180832", + "fw_ver": "1.5.0", "hw_id": "", "need_to_upgrade": false, "oem_id": "", @@ -129,10 +126,405 @@ "release_note": "", "type": 0 }, + "get_temp_humidity_records": { + "local_time": 1739107441, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 58, + 57, + 57, + 57, + 56, + 56, + 55, + 55, + 55, + 55, + 54, + 54, + 55, + 56, + 57, + 57, + 58, + 58, + 58, + 58, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 61, + 82, + 59, + 60, + 61, + 61, + 61, + 61 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 22, + 0, + 0, + 1, + 1, + 1, + 1 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 213, + 213, + 212, + 211, + 210, + 208, + 207, + 206, + 205, + 204, + 203, + 202, + 201, + 202, + 203, + 205, + 206, + 208, + 209, + 210, + 210, + 211, + 211, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 215, + 254, + 221, + 214, + 212, + 211, + 210, + 210 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "temp_unit": "celsius" + }, "get_trigger_logs": { "logs": [], "start_id": 0, "sum": 0 - }, - "qs_component_nego": -1001 + } } diff --git a/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json b/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json index 4fc49b0e8..a9fd67e38 100644 --- a/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json +++ b/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json @@ -1,537 +1,537 @@ { - "component_nego" : { - "component_list" : [ - { - "id" : "device", - "ver_code" : 2 - }, - { - "id" : "quick_setup", - "ver_code" : 3 - }, - { - "id" : "trigger_log", - "ver_code" : 1 - }, - { - "id" : "time", - "ver_code" : 1 - }, - { - "id" : "device_local_time", - "ver_code" : 1 - }, - { - "id" : "account", - "ver_code" : 1 - }, - { - "id" : "synchronize", - "ver_code" : 1 - }, - { - "id" : "cloud_connect", - "ver_code" : 1 - }, - { - "id" : "iot_cloud", - "ver_code" : 1 - }, - { - "id" : "firmware", - "ver_code" : 1 - }, - { - "id" : "localSmart", - "ver_code" : 1 - }, - { - "id" : "battery_detect", - "ver_code" : 1 - }, - { - "id" : "temperature", - "ver_code" : 1 - }, - { - "id" : "humidity", - "ver_code" : 1 - }, - { - "id" : "temp_humidity_record", - "ver_code" : 1 - }, - { - "id" : "comfort_temperature", - "ver_code" : 1 - }, - { - "id" : "comfort_humidity", - "ver_code" : 1 - }, - { - "id" : "report_mode", - "ver_code" : 1 - } - ] - }, - "get_connect_cloud_state" : { - "status" : 0 - }, - "get_device_info" : { - "at_low_battery" : false, - "avatar" : "", - "battery_percentage" : 100, - "bind_count" : 1, - "category" : "subg.trigger.temp-hmdt-sensor", - "current_humidity" : 61, - "current_humidity_exception" : 1, - "current_temp" : 21.4, - "current_temp_exception" : 0, - "device_id" : "SCRUBBED_CHILD_DEVICE_ID_1", - "fw_ver" : "1.7.0 Build 230424 Rel.170332", - "hw_id" : "00000000000000000000000000000000", - "hw_ver" : "1.0", - "jamming_rssi" : -122, - "jamming_signal_level" : 1, - "lastOnboardingTimestamp" : 1706990901, - "mac" : "F0A731000000", - "model" : "T315", - "nickname" : "I01BU0tFRF9OQU1FIw==", - "oem_id" : "00000000000000000000000000000000", - "parent_device_id" : "0000000000000000000000000000000000000000", - "region" : "Europe/Berlin", - "report_interval" : 16, - "rssi" : -56, - "signal_level" : 3, - "specs" : "EU", - "status" : "online", - "status_follow_edge" : false, - "temp_unit" : "celsius", - "type" : "SMART.TAPOSENSOR" - }, - "get_fw_download_state" : { - "cloud_cache_seconds" : 1, - "download_progress" : 0, - "reboot_time" : 5, - "status" : 0, - "upgrade_time" : 5 - }, - "get_latest_fw" : { - "fw_ver" : "1.8.0 Build 230921 Rel.091446", - "hw_id" : "00000000000000000000000000000000", - "need_to_upgrade" : true, - "oem_id" : "00000000000000000000000000000000", - "release_date" : "2023-12-01", - "release_note" : "Modifications and Bug Fixes:\nEnhance the stability of the sensor.", - "type" : 2 - }, - "get_temp_humidity_records" : { - "local_time" : 1709061516, - "past24h_humidity" : [ - 60, - 60, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 58, - 59, - 59, - 58, - 59, - 59, - 59, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 59, - 59, - 59, - 59, - 59, - 59, - 60, - 60, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 59, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 60, - 64, - 56, - 53, - 55, - 56, - 57, - 57, - 58, - 59, - 63, - 63, - 62, - 62, - 62, - 62, - 61, - 62, - 62, - 61, - 61 - ], - "past24h_humidity_exception" : [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 3, - 2, - 2, - 2, - 2, - 1, - 2, - 2, - 1, - 1 - ], - "past24h_temp" : [ - 217, - 216, - 215, - 214, - 214, - 214, - 214, - 214, - 214, - 213, - 213, - 213, - 213, - 213, - 212, - 212, - 211, - 211, - 211, - 211, - 211, - 211, - 212, - 212, - 212, - 211, - 211, - 211, - 211, - 212, - 212, - 212, - 212, - 212, - 211, - 211, - 211, - 212, - 213, - 214, - 214, - 214, - 213, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 212, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 213, - 214, - 214, - 215, - 215, - 215, - 214, - 215, - 216, - 216, - 216, - 216, - 216, - 216, - 216, - 205, - 196, - 210, - 213, - 213, - 213, - 213, - 213, - 214, - 215, - 214, - 214, - 213, - 213, - 214, - 214, - 214, - 213, - 213 - ], - "past24h_temp_exception" : [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - -4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "temp_unit" : "celsius" - }, - "get_trigger_logs" : { - "logs" : [ - { - "event" : "tooDry", - "eventId" : "118040a8-5422-1100-0804-0a8542211000", - "id" : 1, - "timestamp" : 1706996915 - } - ], - "start_id" : 1, - "sum" : 1 - } + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 61, + "current_humidity_exception": 1, + "current_temp": 21.4, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -122, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706990901, + "mac": "F0A731000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -56, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.8.0 Build 230921 Rel.091446", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-01", + "release_note": "Modifications and Bug Fixes:\nEnhance the stability of the sensor.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1709061516, + "past24h_humidity": [ + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 58, + 59, + 59, + 58, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 59, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 64, + 56, + 53, + 55, + 56, + 57, + 57, + 58, + 59, + 63, + 63, + 62, + 62, + 62, + 62, + 61, + 62, + 62, + 61, + 61 + ], + "past24h_humidity_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 3, + 2, + 2, + 2, + 2, + 1, + 2, + 2, + 1, + 1 + ], + "past24h_temp": [ + 217, + 216, + 215, + 214, + 214, + 214, + 214, + 214, + 214, + 213, + 213, + 213, + 213, + 213, + 212, + 212, + 211, + 211, + 211, + 211, + 211, + 211, + 212, + 212, + 212, + 211, + 211, + 211, + 211, + 212, + 212, + 212, + 212, + 212, + 211, + 211, + 211, + 212, + 213, + 214, + 214, + 214, + 213, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 212, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 213, + 214, + 214, + 215, + 215, + 215, + 214, + 215, + 216, + 216, + 216, + 216, + 216, + 216, + 216, + 205, + 196, + 210, + 213, + 213, + 213, + 213, + 213, + 214, + 215, + 214, + 214, + 213, + 213, + 214, + 214, + 214, + 213, + 213 + ], + "past24h_temp_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "tooDry", + "eventId": "118040a8-5422-1100-0804-0a8542211000", + "id": 1, + "timestamp": 1706996915 + } + ], + "start_id": 1, + "sum": 1 + } } diff --git a/tests/fixtures/smartcam/C100_4.0_1.3.14.json b/tests/fixtures/smartcam/C100_4.0_1.3.14.json new file mode 100644 index 000000000..144cf5f69 --- /dev/null +++ b/tests/fixtures/smartcam/C100_4.0_1.3.14.json @@ -0,0 +1,779 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C100", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.14 Build 240513 Rel.43631n(5553)", + "hardware_version": "4.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "staticIp", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "40" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + ".name": "bcd", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-15 11:11:55", + "seconds_from_1970": 1734279115 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -15, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C100 4.0 IPC", + "device_model": "C100", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "4.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.14 Build 240513 Rel.43631n(5553)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC-05:00", + "timing_mode": "manual", + "zone_id": "America/New_York" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "1024", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "40" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + } +} diff --git a/tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json b/tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json new file mode 100644 index 000000000..4b5fbce83 --- /dev/null +++ b/tests/fixtures/smartcam/C101(US)_5.0_1.4.3.json @@ -0,0 +1,1154 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C101", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "4" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 251128 Rel.63757n", + "hardware_version": "5.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-03-8E-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1, + "sv": 1, + "tpap": { + "noc": 1, + "pake": [ + 2 + ], + "port": 443, + "tls": 1 + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound" + ], + "alarm_type": "0", + "alarm_volume": "normal", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "0", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-2359,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "relayPreConnection", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + }, + { + "name": "streamCapability", + "version": 1 + }, + { + "name": "localCtrl", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2026-03-13 18:49:34", + "seconds_from_1970": 1773445774 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -44, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "80", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C101 5.0 IPC", + "device_model": "C101", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "5.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "98-03-8E-00-00-00", + "manufacturer_name": "TP-Link", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.4.3 Build 251128 Rel.63757n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "on" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "80" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "20", + "enabled": "off", + "sensitivity": "low" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-06:00", + "timing_mode": "ntp", + "zone_id": "America/Chicago" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1280", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1280", + "bitrate_type": "vbr", + "default_bitrate": "1024", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCXl67tCOTpn83kKtYzQIy6F6Q7\nsq5QBEeaO639zeE7eyNnh3ZA+PlCVsoICB7Gl1Yu/PmK0gfk3hujpcD4RO6TGIVU\n5C8jt7Hz8fgyzUJcKp3z+QUrf3oiLrOsRqzgieQdYkFh7LY9tQkzTkkxrJpmKmln\n3254L9dKKG3uqaEFXwIDAQAB\n-----END PUBLIC KEY-----\n", + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json b/tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json new file mode 100644 index 000000000..2e78ceb6a --- /dev/null +++ b/tests/fixtures/smartcam/C110(EU)_2.0_1.4.3.json @@ -0,0 +1,960 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C110", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 240919 Rel.70035n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-25-4A-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-02-11 12:32:27", + "seconds_from_1970": 1739230347 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -51, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C110 2.0 IPC", + "device_model": "C110", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "98-25-4A-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.3 Build 240919 Rel.70035n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "113.3GB", + "free_space_accurate": "121601261568B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1734403667", + "rw_attr": "rw", + "status": "normal", + "total_space": "113.5GB", + "total_space_accurate": "121869697024B", + "type": "local", + "video_free_space": "113.3GB", + "video_free_space_accurate": "121601261568B", + "video_total_space": "113.5GB", + "video_total_space_accurate": "121869697024B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+12:00", + "timing_mode": "ntp", + "zone_id": "Pacific/Auckland" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65566", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2304*1296", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json b/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json new file mode 100644 index 000000000..b9e6640b7 --- /dev/null +++ b/tests/fixtures/smartcam/C210(EU)_1.0_1.4.7.json @@ -0,0 +1,1060 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1763150321", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.7 Build 250625 Rel.58841n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "9C-A2-F4-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "relayPreConnection", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-11-28 00:22:51", + "seconds_from_1970": 1764285771 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -49, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 1.0 IPC", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "9C-A2-F4-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.7 Build 250625 Rel.58841n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1763150321", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "on", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "on", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2", + "3" + ], + "name": [ + "Default", + "Door", + "Mid" + ], + "position_pan": [ + "-0.278697", + "-0.277663", + "-0.319545" + ], + "position_tilt": [ + "1.000000", + "-0.040201", + "0.366834" + ], + "position_zoom": [], + "read_only": [ + "0", + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:1\"]", + "monday": "[\"0000-2400:1\"]", + "saturday": "[\"0000-2400:1\"]", + "sunday": "[\"0000-2400:1\"]", + "thursday": "[\"0000-2400:1\"]", + "tuesday": "[\"0000-2400:1\"]", + "wednesday": "[\"0000-2400:1\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1763278827", + "rw_attr": "rw", + "status": "normal", + "total_space": "29.3GB", + "total_space_accurate": "31443156992B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "28.3GB", + "video_total_space_accurate": "30333206528B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1382", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1382", + "bitrate_type": "vbr", + "default_bitrate": "1382", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8", + "16" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json index ba2e00108..609c46bec 100644 --- a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json @@ -1,35 +1,38 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "0000000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "1729264456", - "last_alarm_type": "motion", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "C210", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.4.2 Build 240829 Rel.54953n", - "hardware_version": "2.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-AE-30-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1729264456", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.2 Build 240829 Rel.54953n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": { diff --git a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json index a2f7666ed..d4de5b9f2 100644 --- a/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json @@ -1,35 +1,39 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "0000000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "0", - "last_alarm_type": "", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "C210", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.4.3 Build 241010 Rel.33858n", - "hardware_version": "2.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "40-AE-30-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1733422805", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.4.3 Build 241010 Rel.33858n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 } }, "getAlertConfig": { @@ -263,15 +267,22 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-11-01 13:58:50", - "seconds_from_1970": 1730469530 + "local_time": "2024-12-15 11:28:40", + "seconds_from_1970": 1734262120 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 } } }, "getConnectionType": { "link_type": "wifi", "rssi": "3", - "rssiValue": -57, + "rssiValue": -61, "ssid": "I01BU0tFRF9TU0lEIw==" }, "getDetectionConfig": { @@ -318,7 +329,7 @@ "getFirmwareAutoUpgradeConfig": { "auto_upgrade": { "common": { - "enabled": "on", + "enabled": "off", "random_range": "120", "time": "03:00" } @@ -335,8 +346,8 @@ "getLastAlarmInfo": { "system": { "last_alarm_info": { - "last_alarm_time": "0", - "last_alarm_type": "" + "last_alarm_time": "1733422805", + "last_alarm_type": "motion" } } }, @@ -958,5 +969,35 @@ } } } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } } } diff --git a/tests/fixtures/smartcam/C210_2.0_1.3.11.json b/tests/fixtures/smartcam/C210_2.0_1.3.11.json new file mode 100644 index 000000000..9e53bf053 --- /dev/null +++ b/tests/fixtures/smartcam/C210_2.0_1.3.11.json @@ -0,0 +1,870 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734967724", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.11 Build 240110 Rel.64341n(4555)", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 1 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + ".name": "bcd", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-24 00:19:08", + "seconds_from_1970": 1734999548 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -39, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "60", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C210 2.0 IPC", + "device_model": "C210", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.11 Build 240110 Rel.64341n(4555)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": false, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734967724", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "on" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1" + ], + "name": [ + "Viewpoint 1" + ], + "position_pan": [ + "-0.176836" + ], + "position_tilt": [ + "-0.859297" + ], + "read_only": [ + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + ".name": "target_track_info", + ".type": "target_track_info", + "enabled": "off" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2304*1296", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "2048", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1920*1080", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "motor", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "1.0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "get_motor": { + "get": { + "motor": { + "capability": { + ".name": "capability", + ".type": "ptz", + "absolute_move_supported": "1", + "calibrate_supported": "1", + "continuous_move_supported": "1", + "eflip_mode": [ + "off", + "on" + ], + "home_position_mode": "none", + "limit_supported": "0", + "manual_control_level": [ + "low", + "normal", + "high" + ], + "manual_control_mode": [ + "compatible", + "pedestrian", + "motor_vehicle", + "non_motor_vehicle", + "self_adaptive" + ], + "park_supported": "0", + "pattern_supported": "0", + "plan_supported": "0", + "position_pan_range": [ + "-1.000000", + "1.000000" + ], + "position_tilt_range": [ + "-1.000000", + "1.000000" + ], + "poweroff_save_supported": "1", + "poweroff_save_time_range": [ + "10", + "600" + ], + "preset_number_max": "8", + "preset_supported": "1", + "relative_move_supported": "1", + "reverse_mode": [ + "off", + "on", + "auto" + ], + "scan_supported": "0", + "speed_pan_max": "1.00000", + "speed_tilt_max": "1.000000", + "tour_supported": "0" + } + } + } + } +} diff --git a/tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json new file mode 100644 index 000000000..617acd742 --- /dev/null +++ b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.2.json @@ -0,0 +1,1234 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.2 Build 240914 Rel.55174n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "B0-19-21-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "meowDetection", + "version": 1 + }, + { + "name": "barkDetection", + "version": 1 + }, + { + "name": "glassDetection", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getBarkDetectionConfig": { + "bark_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-18 13:54:46", + "seconds_from_1970": 1737204886 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -37, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "high", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C220 1.0 IPC", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "B0-19-21-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.2.2 Build 240914 Rel.55174n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getGlassDetectionConfig": { + "glass_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMeowDetectionConfig": { + "meow_detection": { + "detection": { + "enabled": "on", + "sensitivity": "50" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2" + ], + "name": [ + "Viewpoint 1", + "Viewpoint 2" + ], + "position_pan": [ + "-0.122544", + "0.172182" + ], + "position_tilt": [ + "1.000000", + "1.000000" + ], + "position_zoom": [], + "read_only": [ + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "manual", + "zone_id": "Europe/Sarajevo" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2560", + "bitrate_type": "vbr", + "default_bitrate": "2560", + "encode_type": "H264", + "frame_rate": "65561", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "1", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json new file mode 100644 index 000000000..5fc86df9b --- /dev/null +++ b/tests/fixtures/smartcam/C220(EU)_1.0_1.2.5.json @@ -0,0 +1,1139 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1750109746", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.5 Build 241224 Rel.40956n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "7C-F1-7E-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_func": [ + "sound", + "light" + ], + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "meowDetection", + "version": 1 + }, + { + "name": "barkDetection", + "version": 1 + }, + { + "name": "glassDetection", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "relayPreConnection", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getBarkDetectionConfig": { + "bark_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-06-16 23:47:00", + "seconds_from_1970": 1750110420 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -54, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C220 1.0 IPC", + "device_model": "C220", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "7C-F1-7E-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.2.5 Build 241224 Rel.40956n", + "tss": false + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getGlassDetectionConfig": { + "glass_detection": { + "detection": { + "enabled": "on", + "sensitivity": "50" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1750109746", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "50", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "off" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "50", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getLinecrossingDetectionConfig": { + "linecrossing_detection": { + "arming_schedule": { + "friday": "[\"0000-2400\"]", + "monday": "[\"0000-2400\"]", + "saturday": "[\"0000-2400\"]", + "sunday": "[\"0000-2400\"]", + "thursday": "[\"0000-2400\"]", + "tuesday": "[\"0000-2400\"]", + "wednesday": "[\"0000-2400\"]" + }, + "detection": { + "enabled": "on" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMeowDetectionConfig": { + "meow_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2560", + "bitrate_type": "vbr", + "default_bitrate": "2560", + "encode_type": "H264", + "frame_rate": "65561", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "1" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "1", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json b/tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json new file mode 100644 index 000000000..24227c41b --- /dev/null +++ b/tests/fixtures/smartcam/C225(US)_2.0_1.0.11.json @@ -0,0 +1,1283 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734729039", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C225", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.0.11 Build 240826 Rel.62730n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-42-A1-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "obd_src": "tplink" + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "meowDetection", + "version": 1 + }, + { + "name": "barkDetection", + "version": 1 + }, + { + "name": "glassDetection", + "version": 1 + }, + { + "name": "alarmDetection", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "hdr", + "version": 1 + }, + { + "name": "homekit", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "bleOnboarding", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "encryption", + "version": 3 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "detectionRegion", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getBarkDetectionConfig": { + "bark_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-20 15:15:46", + "seconds_from_1970": 1734736546 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -9, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c225", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C225 2.0 IPC", + "device_model": "C225", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 0, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.0.11 Build 240826 Rel.62730n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getGlassDetectionConfig": { + "glass_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734729039", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMeowDetectionConfig": { + "meow_detection": { + "detection": { + "enabled": "off", + "sensitivity": "50" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "98.6GB", + "free_space_accurate": "105903970616B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1729454840", + "rw_attr": "rw", + "status": "normal", + "total_space": "118.8GB", + "total_space_accurate": "127531646976B", + "type": "local", + "video_free_space": "98.6GB", + "video_free_space_accurate": "105903970616B", + "video_total_space": "114.0GB", + "video_total_space_accurate": "122406567936B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-08:00", + "timing_mode": "ntp", + "zone_id": "America/Los_Angeles" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "hdrs": [ + "0", + "1" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2688*1520", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2048", + "bitrate_type": "vbr", + "default_bitrate": "2048", + "encode_type": "H264", + "frame_rate": "65551", + "hdr": "0", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2688*1520", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "1", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 4, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json b/tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json new file mode 100644 index 000000000..b04cbd06f --- /dev/null +++ b/tests/fixtures/smartcam/C325WB(EU)_1.0_1.1.17.json @@ -0,0 +1,1065 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734490369", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C325WB", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.1.17 Build 240529 Rel.57938n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "darkLightNightVision", + "version": 3 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "upnpc", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "0" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-18 12:59:13", + "seconds_from_1970": 1734490753 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "2", + "rssiValue": -63, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c100", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C325WB 1.0 IPC", + "device_model": "C325WB", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.1.17 Build 240529 Rel.57938n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734490369", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "off", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "off", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "wtl_night_vision", + "md_night_vision", + "shed_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1733281333", + "rw_attr": "rw", + "status": "normal", + "total_space": "118.8GB", + "total_space_accurate": "127565725696B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "114.0GB", + "video_total_space_accurate": "122406567936B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+10:00", + "timing_mode": "ntp", + "zone_id": "Australia/Brisbane" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "3072" + ], + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65537", + "65546", + "65551", + "65556" + ], + "hdrs": [ + "0", + "1" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2688*1520", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1536", + "bitrate_type": "vbr", + "default_bitrate": "3072", + "encode_type": "H264", + "frame_rate": "65556", + "hdr": "0", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2688*1520", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "wtl_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "30" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "95", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "0" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 5, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json b/tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json new file mode 100644 index 000000000..be5456b72 --- /dev/null +++ b/tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json @@ -0,0 +1,1056 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734967724", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C460", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.0 Build 250910 Rel.70120n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_func": [ + "sound", + "light" + ], + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "5", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "imageStyle", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 3 + }, + { + "name": "dayNightMode", + "version": 2 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "pir", + "version": 3 + }, + { + "name": "battery", + "version": 3 + }, + { + "name": "clips", + "version": 2 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "streamGrab", + "version": 1 + }, + { + "name": "antiTheft", + "version": 3 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "ldc", + "version": 1 + }, + { + "name": "noHubBatteryCam", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartAutoExposure", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + }, + { + "name": "aov", + "version": 1 + }, + { + "name": "hubManage", + "version": 1 + }, + { + "name": "aovSupportHub", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2026-01-12 15:01:13", + "seconds_from_1970": 1768248073 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -26, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "det_sensitivity": "70", + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Swimming pool", + "barcode": "", + "battery_charging": "NO", + "battery_overheated": false, + "battery_percent": 100, + "channel_plan_code": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C460 1.0 IPC", + "device_model": "C460", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "low_battery": false, + "mac": "3C-78-95-00-00-00", + "manufacturer_name": "TP-Link", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "power": "BATTERY", + "power_save_mode": "off", + "power_save_status": "off", + "region": "CA", + "sw_version": "1.2.0 Build 250910 Rel.70120n", + "tss": false + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "1", + "inf_end_time": "21600", + "inf_sensitivity": "-1", + "inf_sensitivity_boot": "180", + "inf_sensitivity_day2night": "131", + "inf_sensitivity_night2day": "1674", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_boot": "0", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "1", + "inf_end_time": "21600", + "inf_sensitivity": "-1", + "inf_sensitivity_boot": "180", + "inf_sensitivity_day2night": "131", + "inf_sensitivity_night2day": "1674", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "original", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_boot": "0", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "56.7GB", + "free_space_accurate": "60924595724B", + "hardware_security_config": "0", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "password": "", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1768153168", + "rw_attr": "rw", + "security_status": "2", + "status": "normal", + "total_space": "59.2GB", + "total_space_accurate": "63580504064B", + "type": "local", + "video_free_space": "56.7GB", + "video_free_space_accurate": "60924595724B", + "video_total_space": "56.8GB", + "video_total_space_accurate": "60934848512B", + "write_protect": "0" + } + } + ] + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-05:00", + "timing_mode": "ntp", + "zone_id": "America/New_York" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "vbr" + ], + "bitrates": [ + "1024", + "1800", + "2048", + "2400", + "4096", + "6144" + ], + "codec_switch_support": "0", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "3840*2160", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2400", + "bitrate_type": "vbr", + "codec_switch_trigger": "none", + "default_bitrate": [ + 4096, + 2400 + ], + "encode_type": "H265", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "3840*2160", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "4" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "image_style_capability": "1", + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "0", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAXfqAdngV6AVbE2CMG8f2I9OM\n/Xh/yWq4usOIqEhGW36Zq+mA2jVlH86hLqPwMeRXJO1teHYd53TVUAgk0US43GkS\n8uSFe9K5PXWt5TeDvLmBw3J85dj/sIDVxNvLrmwUD+Djqo2DLdW8HYvN83HN8Sf+\nLVWsnyRlVXjRjT5zDQIDAQAB\n-----END PUBLIC KEY-----\n", + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json new file mode 100644 index 000000000..c425da795 --- /dev/null +++ b/tests/fixtures/smartcam/C520WS(US)_1.0_1.2.8.json @@ -0,0 +1,1150 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734386954", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C520WS", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.8 Build 240606 Rel.39146n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "5", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 3 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "panoramicView", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "smartTrack", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "off", + "sampling_rate": "8", + "volume": "81" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-16 17:09:43", + "seconds_from_1970": 1734386983 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -45, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c520ws", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C520WS 1.0 IPC", + "device_model": "C520WS", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.2.8 Build 240606 Rel.39146n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734386954", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "off", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "md_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1", + "2", + "3", + "4" + ], + "name": [ + "Doorbell", + "Packages", + "Street", + "Arm" + ], + "position_pan": [ + "-0.328380", + "0.010401", + "0.010401", + "0.066865" + ], + "position_tilt": [ + "-0.062500", + "0.828125", + "-0.285156", + "0.160156" + ], + "position_zoom": [], + "read_only": [ + "0", + "0", + "0", + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-05:00", + "timing_mode": "manual", + "zone_id": "America/New_York" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "0", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080", + "1280*720" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2560", + "bitrate_type": "vbr", + "default_bitrate": "2560", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "50", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "off", + "sampling_rate": "8", + "volume": "81" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json b/tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json new file mode 100644 index 000000000..e31bee028 --- /dev/null +++ b/tests/fixtures/smartcam/C720(US)_1.0_1.2.3.json @@ -0,0 +1,1039 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1736360289", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "normal" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "C720", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.2.3 Build 240823 Rel.40327n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "98-25-4A-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "pirDetection", + "version": 1 + }, + { + "name": "lightsensor", + "version": 1 + }, + { + "name": "floodlight", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "staticIp", + "version": 2 + }, + { + "name": "manualAlarm", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "timeFormat", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-08 12:24:34", + "seconds_from_1970": 1736360674 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -55, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c720", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "C720 1.0 IPC", + "device_model": "C720", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "98-25-4A-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.2.3 Build 240823 Rel.40327n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1736360661", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "manual_exp_iso_gain": "0", + "manual_exp_us": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "manual_exp_iso_gain": "0", + "manual_exp_us": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "6.5GB", + "free_space_accurate": "6945154936B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "100", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1706216554", + "rw_attr": "rw", + "status": "normal", + "total_space": "119.1GB", + "total_space_accurate": "127878135808B", + "type": "local", + "video_free_space": "6.5GB", + "video_free_space_accurate": "6945154936B", + "video_total_space": "114.2GB", + "video_total_space_accurate": "122675003392B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-06:00", + "timing_mode": "ntp", + "zone_id": "America/Chicago" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1440", + "1920*1080" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "2048", + "bitrate_type": "vbr", + "default_bitrate": "2048", + "encode_type": "H264", + "frame_rate": "65561", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1440", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "center", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "factory_noise_cancelling": "off", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "0", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json b/tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json new file mode 100644 index 000000000..7cd498f7f --- /dev/null +++ b/tests/fixtures/smartcam/D130(US)_1.0_1.1.9.json @@ -0,0 +1,986 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "D130", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.1.9 Build 240716 Rel.51615n", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "40-AE-30-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "15", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "light", + "sound" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Tone" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 2 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 3 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "linecrossingDetection", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "packageDetection", + "version": 3 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "markerBox", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "quickResponse", + "version": 1 + }, + { + "name": "ldc", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "chimeCtrl", + "version": 1 + }, + { + "name": "ring", + "version": 3 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-09 08:38:30", + "seconds_from_1970": 1736433510 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -46, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "off", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera d130", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "D130 1.0 IPC", + "device_model": "D130", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "features": 3, + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "no_rtsp_constrain": 1, + "oem_id": "00000000000000000000000000000000", + "region": "US", + "sw_version": "1.1.9 Build 240716 Rel.51615n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": "120", + "time": "15:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1736432241", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "auto" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "100", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "50", + "smartwtl_level": "3", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "md_night_vision", + "dbl_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "normal", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "1", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "1723813993", + "rw_attr": "rw", + "status": "normal", + "total_space": "119.1GB", + "total_space_accurate": "127878135808B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "114.3GB", + "video_total_space_accurate": "122675003392B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-06:00", + "timing_mode": "ntp", + "zone_id": "America/Chicago" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1536", + "2048", + "2560", + "3072" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561", + "65566" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "2560*1920" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "3072", + "bitrate_type": "vbr", + "default_bitrate": "3072", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "2560*1920", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "30", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "on", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8" + ], + "system_volume": "80", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "80" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "80" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "ai_firmware_upgrade": "0", + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "OSD", + "image", + "video" + ], + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "force_iframe_support": "1", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "ir_led_pwm_control": "1", + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "motor": "0", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "personalized_audio_alarm": "0", + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "ptz": "0", + "record_max_slot_cnt": "6", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "0", + "smart_codec": "0", + "smart_detection": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "storage_capability": "1", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "wide_range_inf_sensitivity": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "true" + } + } + } +} diff --git a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json index 04bcc262c..4ef99fae2 100644 --- a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json +++ b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json @@ -1,33 +1,37 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "", - "connect_type": "wired", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "H200", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.TAPOHUB", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.3.2 Build 20240424 rel.75425", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-6E-84-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.2 Build 20240424 rel.75425", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": {}, @@ -211,8 +215,8 @@ "fw_ver": "1.11.0 Build 230821 Rel.113553", "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", - "jamming_rssi": -108, - "jamming_signal_level": 2, + "jamming_rssi": -119, + "jamming_signal_level": 1, "lastOnboardingTimestamp": 1714016798, "mac": "202351000000", "model": "S200B", @@ -221,7 +225,7 @@ "parent_device_id": "0000000000000000000000000000000000000000", "region": "Europe/London", "report_interval": 16, - "rssi": -66, + "rssi": -60, "signal_level": 3, "specs": "EU", "status": "online", @@ -242,8 +246,17 @@ "getClockStatus": { "system": { "clock_status": { - "local_time": "2024-11-01 13:56:27", - "seconds_from_1970": 1730469387 + "local_time": "1984-10-21 23:48:23", + "seconds_from_1970": 467246903 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "current_ssid": "", + "err_code": 0, + "status": 0 } } }, @@ -326,6 +339,10 @@ } } }, + "getMatterSetupInfo": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, "getMediaEncrypt": { "cet": { "media_encrypt": { @@ -350,7 +367,7 @@ "getSirenConfig": { "duration": 300, "siren_type": "Doorbell Ring 1", - "volume": "6" + "volume": "1" }, "getSirenStatus": { "status": "off", @@ -386,5 +403,98 @@ "zone_id": "Europe/London" } } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 0, + "bssid": "000000000000", + "encryption": 0, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 3, + "bssid": "000000000000", + "encryption": 2, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } } } diff --git a/tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json new file mode 100644 index 000000000..99460fe18 --- /dev/null +++ b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.6.json @@ -0,0 +1,556 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.6 Build 20240829 rel.71119", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "F0-09-0D-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertConfig": {}, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "dateTime", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "hubRecord", + "version": 1 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "siren", + "version": 2 + }, + { + "name": "childControl", + "version": 1 + }, + { + "name": "childQuickSetup", + "version": 1 + }, + { + "name": "childInherit", + "version": 1 + }, + { + "name": "deviceLoad", + "version": 1 + }, + { + "name": "subg", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "preWakeUp", + "version": 1 + }, + { + "name": "supportRE", + "version": 1 + }, + { + "name": "testSignal", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "testChildSignal", + "version": 1 + }, + { + "name": "ringLog", + "version": 1 + }, + { + "name": "matter", + "version": 1 + }, + { + "name": "localSmart", + "version": 1 + }, + { + "name": "generalCameraManage", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "hubPlayback", + "version": 1 + } + ] + } + }, + "getChildDeviceComponentList": { + "child_component_list": [ + { + "component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "batCamSystem", + "version": 1 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 3 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "firmware", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 2 + }, + { + "name": "dayNightMode", + "version": 2 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "batCamOsd", + "version": 1 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "pir", + "version": 1 + }, + { + "name": "battery", + "version": 3 + }, + { + "name": "clips", + "version": 1 + }, + { + "name": "batCamRelay", + "version": 1 + }, + { + "name": "batCamP2p", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "packageDetection", + "version": 3 + }, + { + "name": "wakeUp", + "version": 1 + }, + { + "name": "ring", + "version": 1 + }, + { + "name": "antiTheft", + "version": 3 + }, + { + "name": "quickResponse", + "version": 2 + }, + { + "name": "doorbellNightVision", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "streamGrab", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "batCamPreRelay", + "version": 1 + }, + { + "name": "batCamStatistics", + "version": 1 + }, + { + "name": "batCamNodeRelay", + "version": 1 + }, + { + "name": "batCamRtsp", + "version": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + } + ], + "start_index": 0, + "sum": 1 + }, + "getChildDeviceList": { + "child_device_list": [ + { + "alias": "#MASKED_NAME#", + "anti_theft_status": 0, + "avatar": "camera d210", + "battery_charging": "NO", + "battery_installed": 1, + "battery_percent": 90, + "battery_temperature": 3, + "battery_voltage": 4022, + "cam_uptime": 5378, + "category": "camera", + "dev_name": "Tapo Smart Doorbell", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_model": "D230", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "ext_addr": "0000000000000000", + "firmware_status": "OK", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.20", + "ipaddr": "172.23.30.2", + "led_status": "on", + "low_battery": false, + "mac": "F0:09:0D:00:00:00", + "oem_id": "00000000000000000000000000000000", + "onboarding_timestamp": 1732920657, + "online": true, + "parent_device_id": "0000000000000000000000000000000000000000", + "power": "BATTERY", + "power_save_mode": "off", + "power_save_status": "off", + "region": "EU", + "rssi": -46, + "short_addr": 0, + "status": "configured", + "subg_cam_rssi": 0, + "subg_hub_rssi": 0, + "sw_ver": "1.1.19 Build 20241011 rel.67020", + "system_time": 1735995953, + "updating": false, + "uptime": 3061186 + } + ], + "start_index": 0, + "sum": 1 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-04 14:05:53", + "seconds_from_1970": 1735995953 + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 1, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + }, + "info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 1, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H200 1.0", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "F0-09-0D-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": 30, + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getMatterSetupInfo": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "loop_record_status": "1", + "status": "offline" + } + } + ] + } + }, + "getSirenConfig": { + "duration": 30, + "siren_type": "Doorbell Ring 3", + "volume": "10" + }, + "getSirenStatus": { + "status": "off", + "time_left": 0 + }, + "getSirenTypeList": { + "siren_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "zone_id": "Europe/Amsterdam" + } + } + } +} diff --git a/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json index f1a6ae157..26c037936 100644 --- a/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json +++ b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json @@ -1,34 +1,37 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "", - "connect_type": "wired", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "H200", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.TAPOHUB", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.3.6 Build 20240829 rel.71119", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "isResetWiFi": false, - "is_support_iot_cloud": true, - "mac": "24-2F-D0-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H200", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.6 Build 20240829 rel.71119", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "24-2F-D0-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertConfig": {}, diff --git a/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json b/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json new file mode 100644 index 000000000..9d625f735 --- /dev/null +++ b/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json @@ -0,0 +1,1037 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC40", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": true, + "firmware_version": "1.0.4 Build 240902 Rel.38194n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "3C-64-CF-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "90" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-09-02 03:14:19", + "seconds_from_1970": 1725246859 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "-1", + "rssiValue": 0, + "ssid": "" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC40 2.0 IPC", + "device_model": "TC40", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 0, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "3C-64-CF-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.0.4 Build 240902 Rel.38194n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "md_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "off", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1228", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1228", + "bitrate_type": "vbr", + "default_bitrate": "1228", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "90" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} diff --git a/tests/fixtures/smartcam/TC65_1.0_1.3.9.json b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json index 5b05a1b3d..cec6b7595 100644 --- a/tests/fixtures/smartcam/TC65_1.0_1.3.9.json +++ b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json @@ -1,35 +1,38 @@ { "discovery_result": { - "decrypted_data": { - "connect_ssid": "0000000", - "connect_type": "wireless", - "device_id": "0000000000000000000000000000000000000000", - "http_port": 443, - "last_alarm_time": "1698149810", - "last_alarm_type": "motion", - "owner": "00000000000000000000000000000000", - "sd_status": "offline" - }, - "device_id": "00000000000000000000000000000000", - "device_model": "TC65", - "device_name": "#MASKED_NAME#", - "device_type": "SMART.IPCAMERA", - "encrypt_info": { - "data": "", - "key": "", - "sym_schm": "AES" - }, - "encrypt_type": [ - "3" - ], - "factory_default": false, - "firmware_version": "1.3.9 Build 231024 Rel.72919n(4555)", - "hardware_version": "1.0", - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "A8-6E-84-00-00-00", - "mgt_encrypt_schm": { - "is_support_https": true + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1698149810", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC65", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.9 Build 231024 Rel.72919n(4555)", + "hardware_version": "1.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "A8-6E-84-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } } }, "getAlertPlan": { diff --git a/tests/fixtures/smartcam/TC70_3.0_1.3.11.json b/tests/fixtures/smartcam/TC70_3.0_1.3.11.json new file mode 100644 index 000000000..b57269820 --- /dev/null +++ b/tests/fixtures/smartcam/TC70_3.0_1.3.11.json @@ -0,0 +1,870 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "#MASKED_SSID#", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "1734271551", + "last_alarm_type": "motion", + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC70", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "firmware_version": "1.3.11 Build 231121 Rel.39429n(4555)", + "hardware_version": "3.0", + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-E9-31-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + } + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + ".name": "chn1_msg_alarm_plan", + ".type": "plan", + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 1 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 2 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 2 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "babyCryDetection", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + }, + "getBCDConfig": { + "sound_detection": { + "bcd": { + ".name": "bcd", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-12-18 22:59:11", + "seconds_from_1970": 1734562751 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "4", + "rssiValue": -50, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + ".name": "motion_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "on", + "sensitivity": "medium" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "camera c212", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC70 3.0 IPC", + "device_model": "TC70", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "3.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.11 Build 231121 Rel.39429n(4555)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "off", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": false, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1734271551", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + }, + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + ".name": "lens_mask_info", + ".type": "lens_mask_info", + "enabled": "on" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + ".name": "common", + ".type": "para", + "area_compensation": "default", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "exp_gain": "0", + "exp_type": "auto", + "focus_limited": "600", + "focus_type": "semi_auto", + "high_light_compensation": "off", + "inf_delay": "10", + "inf_end_time": "21600", + "inf_sensitivity": "1", + "inf_start_time": "64800", + "inf_type": "auto", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "off", + "smartir_level": "100", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + ".name": "media_encrypt", + ".type": "on_off", + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + ".name": "chn1_msg_push_info", + ".type": "on_off", + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + ".name": "detection", + ".type": "on_off", + "enabled": "off" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [ + "1" + ], + "name": [ + "Viewpoint 1" + ], + "position_pan": [ + "0.088935" + ], + "position_tilt": [ + "-1.000000" + ], + "read_only": [ + "0" + ] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + ".name": "chn1_channel", + ".type": "plan", + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_total_space": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_total_space": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "type": "local", + "video_free_space": "0B", + "video_total_space": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + ".name": "tamper_det", + ".type": "on_off", + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + ".name": "target_track_info", + ".type": "target_track_info", + "enabled": "off" + } + } + }, + "getTimezone": { + "system": { + "basic": { + ".name": "basic", + ".type": "setting", + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + ".name": "main", + ".type": "capability", + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "2048" + ], + "encode_types": [ + "H264" + ], + "frame_rates": [ + "65537", + "65546", + "65551" + ], + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + ".name": "main", + ".type": "stream", + "bitrate": "1024", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "gop_factor": "2", + "name": "VideoEncoder_1", + "quality": "3", + "resolution": "1280*720", + "stream_type": "general" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + ".name": "switch", + ".type": "switch_type", + "flip_type": "off", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + ".name": "device_microphone", + ".type": "capability", + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + ".name": "device_speaker", + ".type": "capability", + "channels": "1", + "decode_type": [ + "G711" + ], + "mute": "0", + "sampling_rate": [ + "8" + ], + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + ".name": "microphone", + ".type": "audio_config", + "channels": "1", + "encode_type": "G711alaw", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "100" + }, + "speaker": { + ".name": "speaker", + ".type": "audio_config", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + ".name": "vhttpd", + ".type": "server", + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + ".name": "module_spec", + ".type": "module-spec", + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audioexception_detection": "0", + "auth_encrypt": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "custom_area_compensation": "1", + "custom_auto_mode_exposure_level": "0", + "device_share": [ + "preview", + "playback", + "voice", + "motor", + "cloud_storage" + ], + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "greeter": "1.0", + "http_system_state_audio_support": "1", + "intrusion_detection": "1", + "led": "1", + "lens_mask": "1", + "linecrossing_detection": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_alarm_separate_list": [ + "light", + "sound" + ], + "msg_push": "1", + "multi_user": "0", + "multicast": "0", + "network": [ + "wifi" + ], + "ota_upgrade": "1", + "p2p_support_versions": [ + "1.1" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "1.3" + ], + "reonboarding": "1", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "1.0" + ], + "target_track": "1.0", + "timing_reboot": "1", + "verification_change_password": "1", + "video_codec": [ + "h264" + ], + "video_detection_digital_sensitivity": "1", + "wifi_cascade_connection": "1", + "wifi_connection_info": "1", + "wireless_hotspot": "1" + } + } + } + }, + "get_motor": { + "get": { + "motor": { + "capability": { + ".name": "capability", + ".type": "ptz", + "absolute_move_supported": "1", + "calibrate_supported": "1", + "continuous_move_supported": "1", + "eflip_mode": [ + "off", + "on" + ], + "home_position_mode": "none", + "limit_supported": "0", + "manual_control_level": [ + "low", + "normal", + "high" + ], + "manual_control_mode": [ + "compatible", + "pedestrian", + "motor_vehicle", + "non_motor_vehicle", + "self_adaptive" + ], + "park_supported": "0", + "pattern_supported": "0", + "plan_supported": "0", + "position_pan_range": [ + "-1.000000", + "1.000000" + ], + "position_tilt_range": [ + "-1.000000", + "1.000000" + ], + "poweroff_save_supported": "1", + "poweroff_save_time_range": [ + "10", + "600" + ], + "preset_number_max": "8", + "preset_supported": "1", + "relative_move_supported": "1", + "reverse_mode": [ + "off", + "on", + "auto" + ], + "scan_supported": "0", + "speed_pan_max": "1.00000", + "speed_tilt_max": "1.000000", + "tour_supported": "0" + } + } + } + } +} diff --git a/tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json b/tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json new file mode 100644 index 000000000..83ed36c17 --- /dev/null +++ b/tests/fixtures/smartcam/child/D230(EU)_1.20_1.1.19.json @@ -0,0 +1,525 @@ +{ + "child_info_from_parent": { + "alias": "#MASKED_NAME#", + "anti_theft_status": 0, + "avatar": "camera d210", + "battery_charging": "NO", + "battery_installed": 1, + "battery_percent": 90, + "battery_temperature": 5, + "battery_voltage": 4073, + "cam_uptime": 5420, + "category": "camera", + "dev_name": "Tapo Smart Doorbell", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_model": "D230", + "device_name": "D230 1.20", + "device_type": "SMART.TAPODOORBELL", + "ext_addr": "0000000000000000", + "firmware_status": "OK", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.20", + "ipaddr": "172.23.30.2", + "led_status": "on", + "low_battery": false, + "mac": "F0:09:0D:00:00:00", + "oem_id": "00000000000000000000000000000000", + "onboarding_timestamp": 1732920657, + "online": true, + "parent_device_id": "0000000000000000000000000000000000000000", + "power": "BATTERY", + "power_save_mode": "off", + "power_save_status": "off", + "region": "EU", + "rssi": -43, + "short_addr": 0, + "status": "configured", + "subg_cam_rssi": 0, + "subg_hub_rssi": 0, + "sw_ver": "1.1.19 Build 20241011 rel.67020", + "system_time": 1735996806, + "updating": false, + "uptime": 3062029 + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 2 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "batCamSystem", + "version": 1 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 3 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "firmware", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 2 + }, + { + "name": "dayNightMode", + "version": 2 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "batCamOsd", + "version": 1 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "vehicleDetection", + "version": 1 + }, + { + "name": "petDetection", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "pir", + "version": 1 + }, + { + "name": "battery", + "version": 3 + }, + { + "name": "clips", + "version": 1 + }, + { + "name": "batCamRelay", + "version": 1 + }, + { + "name": "batCamP2p", + "version": 1 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "infLamp", + "version": 1 + }, + { + "name": "packageDetection", + "version": 3 + }, + { + "name": "wakeUp", + "version": 1 + }, + { + "name": "ring", + "version": 1 + }, + { + "name": "antiTheft", + "version": 3 + }, + { + "name": "quickResponse", + "version": 2 + }, + { + "name": "doorbellNightVision", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "streamGrab", + "version": 1 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "batCamPreRelay", + "version": 1 + }, + { + "name": "batCamStatistics", + "version": 1 + }, + { + "name": "batCamNodeRelay", + "version": 1 + }, + { + "name": "batCamRtsp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "channels": "1", + "encode_type": "G711ulaw", + "sampling_rate": "8", + "volume": "58" + }, + "microphone_algo": { + "aec": "on", + "hs": "off", + "ns": "off", + "sys_aec": "on" + }, + "record_audio": { + "enabled": "on" + }, + "speaker": { + "volume": "80" + }, + "speaker_algo": { + "hs": "off", + "ns": "off" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2025-01-04 14:20:10", + "seconds_from_1970": 1735996810 + } + } + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "30", + "enabled": "on", + "sensitivity": "low" + }, + "region_info": [] + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "a_type": 3, + "anti_theft_status": 0, + "avatar": "camera d210", + "battery_charging": "NO", + "battery_overheated": false, + "battery_percent": 90, + "c_opt": [ + 0, + 1 + ], + "camera_switch": "on", + "dev_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_alias": "#MASKED_NAME#", + "device_model": "D230", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPODOORBELL", + "firmware_status": "OK", + "hw_version": "1.20", + "last_activity_timestamp": 1735996775, + "led_status": "on", + "low_battery": false, + "mac": "F0-09-0D-00-00-00", + "oem_id": "00000000000000000000000000000000", + "online": true, + "parent_device_id": "0000000000000000000000000000000000000000", + "parent_link_type": "ethernet", + "power": "BATTERY", + "power_save_mode": "off", + "resolution": "2560*1920", + "rssi": -43, + "status": "configured", + "sw_version": "1.1.19 Build 20241011 rel.67020", + "system_time": 1735996808, + "updating": false + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1735996775", + "last_alarm_type": "motion" + } + } + }, + "getLdc": { + "image": { + "switch": { + "ldc": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "light_freq_mode": "50" + } + } + }, + "getLightTypeList": { + "light_type_list": [ + "flicker" + ] + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "dbl_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "night_vision_mode": "dbl_night_vision" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPetDetectionConfig": { + "pet_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "on", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "flip_type": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "offline", + "disk_name": "1", + "loop_record_status": "1", + "status": "offline" + } + } + ] + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "getVehicleDetectionConfig": { + "vehicle_detection": { + "detection": { + "enabled": "off", + "sensitivity": "60" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "vbr" + ], + "bitrates": [ + "1457" + ], + "change_fps_support": "0", + "encode_types": [ + "H264" + ], + "frame_rates": [ + 65551 + ], + "minor_stream_support": "1", + "qualities": [ + "5" + ], + "resolutions": [ + "2560*1920" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1943", + "bitrate_type": "vbr", + "encode_type": "H264", + "frame_rate": "65551", + "quality": "5", + "resolution": "2560*1920" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "wtl_force_time": "300", + "wtl_intensity_level": "5" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + } +} diff --git a/tests/iot/modules/test_ambientlight.py b/tests/iot/modules/test_ambientlight.py index ff2bd92c2..9abae4054 100644 --- a/tests/iot/modules/test_ambientlight.py +++ b/tests/iot/modules/test_ambientlight.py @@ -8,7 +8,7 @@ @dimmer_iot -def test_ambientlight_getters(dev: IotDimmer): +def test_ambientlight_getters(dev: IotDimmer) -> None: assert Module.IotAmbientLight in dev.modules ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] @@ -22,7 +22,7 @@ def test_ambientlight_getters(dev: IotDimmer): @dimmer_iot -async def test_ambientlight_setters(dev: IotDimmer, mocker: MockerFixture): +async def test_ambientlight_setters(dev: IotDimmer, mocker: MockerFixture) -> None: ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") @@ -36,7 +36,7 @@ async def test_ambientlight_setters(dev: IotDimmer, mocker: MockerFixture): @dimmer_iot -def test_ambientlight_feature(dev: IotDimmer): +def test_ambientlight_feature(dev: IotDimmer) -> None: assert Module.IotAmbientLight in dev.modules ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] diff --git a/tests/iot/modules/test_cloud.py b/tests/iot/modules/test_cloud.py index ec7f8f834..9c4aca98c 100644 --- a/tests/iot/modules/test_cloud.py +++ b/tests/iot/modules/test_cloud.py @@ -4,7 +4,7 @@ @device_iot -def test_cloud(dev: Device): +def test_cloud(dev: Device) -> None: cloud = dev.modules.get(Module.IotCloud) assert cloud info = cloud.info diff --git a/tests/iot/modules/test_dimmer.py b/tests/iot/modules/test_dimmer.py new file mode 100644 index 000000000..a5a009045 --- /dev/null +++ b/tests/iot/modules/test_dimmer.py @@ -0,0 +1,204 @@ +from datetime import timedelta +from typing import Final + +import pytest +from pytest_mock import MockerFixture + +from kasa import KasaException, Module +from kasa.iot import IotDimmer +from kasa.iot.modules.dimmer import Dimmer + +from ...device_fixtures import dimmer_iot + +_TD_ONE_MS: Final[timedelta] = timedelta(milliseconds=1) + + +@dimmer_iot +def test_dimmer_getters(dev: IotDimmer) -> None: + assert Module.IotDimmer in dev.modules + dimmer: Dimmer = dev.modules[Module.IotDimmer] + + assert dimmer.threshold_min == dimmer.config["minThreshold"] + assert int(dimmer.fade_off_time / _TD_ONE_MS) == dimmer.config["fadeOffTime"] + assert int(dimmer.fade_on_time / _TD_ONE_MS) == dimmer.config["fadeOnTime"] + assert int(dimmer.gentle_off_time / _TD_ONE_MS) == dimmer.config["gentleOffTime"] + assert int(dimmer.gentle_on_time / _TD_ONE_MS) == dimmer.config["gentleOnTime"] + assert dimmer.ramp_rate == dimmer.config["rampRate"] + + +@dimmer_iot +async def test_dimmer_setters(dev: IotDimmer, mocker: MockerFixture) -> None: + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = 10 + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold} + ) + + test_time = 100 + await dimmer.set_fade_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time} + ) + await dimmer.set_fade_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time} + ) + + test_time = 1000 + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time} + ) + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time} + ) + + test_rate = 30 + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate} + ) + + +@dimmer_iot +async def test_dimmer_setter_min(dev: IotDimmer, mocker: MockerFixture) -> None: + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MIN + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold} + ) + + test_time = int(dimmer.FADE_TIME_ABS_MIN / _TD_ONE_MS) + await dimmer.set_fade_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time} + ) + await dimmer.set_fade_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time} + ) + + test_time = int(dimmer.GENTLE_TIME_ABS_MIN / _TD_ONE_MS) + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time} + ) + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time} + ) + + test_rate = dimmer.RAMP_RATE_ABS_MIN + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate} + ) + + +@dimmer_iot +async def test_dimmer_setter_max(dev: IotDimmer, mocker: MockerFixture) -> None: + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MAX + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "calibrate_brightness", {"minThreshold": test_threshold} + ) + + test_time = int(dimmer.FADE_TIME_ABS_MAX / _TD_ONE_MS) + await dimmer.set_fade_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_off_time", {"fadeTime": test_time} + ) + await dimmer.set_fade_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_fade_on_time", {"fadeTime": test_time} + ) + + test_time = int(dimmer.GENTLE_TIME_ABS_MAX / _TD_ONE_MS) + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_off_time", {"duration": test_time} + ) + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_gentle_on_time", {"duration": test_time} + ) + + test_rate = dimmer.RAMP_RATE_ABS_MAX + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_called_with( + "smartlife.iot.dimmer", "set_button_ramp_rate", {"rampRate": test_rate} + ) + + +@dimmer_iot +async def test_dimmer_setters_min_oob(dev: IotDimmer, mocker: MockerFixture) -> None: + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MIN - 1 + with pytest.raises(KasaException): + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_not_called() + + test_time = dimmer.FADE_TIME_ABS_MIN - _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_fade_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_fade_on_time(test_time) + query_helper.assert_not_called() + + test_time = dimmer.GENTLE_TIME_ABS_MIN - _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_not_called() + + test_rate = dimmer.RAMP_RATE_ABS_MIN - 1 + with pytest.raises(KasaException): + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_not_called() + + +@dimmer_iot +async def test_dimmer_setters_max_oob(dev: IotDimmer, mocker: MockerFixture) -> None: + dimmer: Dimmer = dev.modules[Module.IotDimmer] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + test_threshold = dimmer.THRESHOLD_ABS_MAX + 1 + with pytest.raises(KasaException): + await dimmer.set_threshold_min(test_threshold) + query_helper.assert_not_called() + + test_time = dimmer.FADE_TIME_ABS_MAX + _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_fade_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_fade_on_time(test_time) + query_helper.assert_not_called() + + test_time = dimmer.GENTLE_TIME_ABS_MAX + _TD_ONE_MS + with pytest.raises(KasaException): + await dimmer.set_gentle_off_time(test_time) + query_helper.assert_not_called() + with pytest.raises(KasaException): + await dimmer.set_gentle_on_time(test_time) + query_helper.assert_not_called() + + test_rate = dimmer.RAMP_RATE_ABS_MAX + 1 + with pytest.raises(KasaException): + await dimmer.set_ramp_rate(test_rate) + query_helper.assert_not_called() diff --git a/tests/test_emeter.py b/tests/iot/modules/test_emeter.py similarity index 61% rename from tests/test_emeter.py rename to tests/iot/modules/test_emeter.py index e796ffee1..160cd5a5d 100644 --- a/tests/test_emeter.py +++ b/tests/iot/modules/test_emeter.py @@ -14,11 +14,7 @@ from kasa.interfaces.energy import Energy from kasa.iot import IotDevice, IotStrip from kasa.iot.modules.emeter import Emeter -from kasa.smart import SmartDevice -from kasa.smart.modules import Energy as SmartEnergyModule -from kasa.smart.smartmodule import SmartModule - -from .conftest import has_emeter, has_emeter_iot, no_emeter +from tests.conftest import has_emeter_iot, no_emeter_iot CURRENT_CONSUMPTION_SCHEMA = Schema( Any( @@ -40,30 +36,23 @@ ) -@no_emeter -async def test_no_emeter(dev): +@no_emeter_iot +async def test_no_emeter(dev: IotDevice) -> None: assert not dev.has_emeter with pytest.raises(AttributeError): - await dev.get_emeter_realtime() - # Only iot devices support the historical stats so other - # devices will not implement the methods below - if isinstance(dev, IotDevice): - with pytest.raises(AttributeError): - await dev.get_emeter_daily() - with pytest.raises(AttributeError): - await dev.get_emeter_monthly() - with pytest.raises(AttributeError): - await dev.erase_emeter_stats() - - -@has_emeter -async def test_get_emeter_realtime(dev): - if isinstance(dev, SmartDevice): - mod = SmartEnergyModule(dev, str(Module.Energy)) - if not await mod._check_supported(): - pytest.skip(f"Energy module not supported for {dev}.") + await dev.get_emeter_realtime() # type: ignore[attr-defined] + + with pytest.raises(AttributeError): + await dev.get_emeter_daily() # type: ignore[attr-defined] + with pytest.raises(AttributeError): + await dev.get_emeter_monthly() # type: ignore[attr-defined] + with pytest.raises(AttributeError): + await dev.erase_emeter_stats() # type: ignore[attr-defined] + +@has_emeter_iot +async def test_get_emeter_realtime(dev: IotDevice) -> None: emeter = dev.modules[Module.Energy] current_emeter = await emeter.get_status() @@ -76,7 +65,7 @@ async def test_get_emeter_realtime(dev): @has_emeter_iot @pytest.mark.requires_dummy -async def test_get_emeter_daily(dev): +async def test_get_emeter_daily(dev: IotDevice) -> None: emeter = dev.modules[Module.Energy] assert await emeter.get_daily_stats(year=1900, month=1) == {} @@ -96,7 +85,7 @@ async def test_get_emeter_daily(dev): @has_emeter_iot @pytest.mark.requires_dummy -async def test_get_emeter_monthly(dev): +async def test_get_emeter_monthly(dev: IotDevice) -> None: emeter = dev.modules[Module.Energy] assert await emeter.get_monthly_stats(year=1900) == {} @@ -115,7 +104,7 @@ async def test_get_emeter_monthly(dev): @has_emeter_iot -async def test_emeter_status(dev): +async def test_emeter_status(dev: IotDevice) -> None: emeter = dev.modules[Module.Energy] d = await emeter.get_status() @@ -136,22 +125,22 @@ async def test_emeter_status(dev): @pytest.mark.skip("not clearing your stats..") -@has_emeter -async def test_erase_emeter_stats(dev): +@has_emeter_iot +async def test_erase_emeter_stats(dev: IotDevice) -> None: emeter = dev.modules[Module.Energy] await emeter.erase_emeter() @has_emeter_iot -async def test_current_consumption(dev): +async def test_current_consumption(dev: IotDevice) -> None: emeter = dev.modules[Module.Energy] x = emeter.current_consumption assert isinstance(x, float) assert x >= 0.0 -async def test_emeterstatus_missing_current(): +async def test_emeterstatus_missing_current() -> None: """KL125 does not report 'current' for emeter.""" regular = EmeterStatus( {"err_code": 0, "power_mw": 0, "total_wh": 13, "current_ma": 123} @@ -165,13 +154,13 @@ async def test_emeterstatus_missing_current(): assert missing_current["current"] is None -async def test_emeter_daily(): +async def test_emeter_daily() -> None: """Test fetching the emeter for today. This test uses inline data since the fixtures will not have data for the current day. """ - emeter_data = { + emeter_data: dict = { "get_daystat": { "day_list": [{"day": 1, "energy_wh": 8, "month": 1, "year": 2023}], "err_code": 0, @@ -191,37 +180,22 @@ def data(self): assert emeter.consumption_today == 0.500 -@has_emeter -async def test_supported(dev: Device): - if isinstance(dev, SmartDevice): - mod = SmartEnergyModule(dev, str(Module.Energy)) - if not await mod._check_supported(): - pytest.skip(f"Energy module not supported for {dev}.") +@has_emeter_iot +async def test_supported(dev: Device) -> None: energy_module = dev.modules.get(Module.Energy) assert energy_module - if isinstance(dev, IotDevice): - info = ( - dev._last_update - if not isinstance(dev, IotStrip) - else dev.children[0].internal_state - ) - emeter = info[energy_module._module]["get_realtime"] - has_total = "total" in emeter or "total_wh" in emeter - has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter - assert ( - energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total - ) - assert ( - energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) - is has_voltage_current - ) - assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True - else: - assert isinstance(energy_module, SmartModule) - assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False - assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False - if energy_module.supported_version < 2: - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False - else: - assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True + info = ( + dev._last_update + if not isinstance(dev, IotStrip) + else dev.children[0].internal_state + ) + emeter = info[energy_module._module]["get_realtime"] + has_total = "total" in emeter or "total_wh" in emeter + has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total + assert ( + energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) + is has_voltage_current + ) + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True diff --git a/tests/iot/modules/test_homekit.py b/tests/iot/modules/test_homekit.py new file mode 100644 index 000000000..c542df4e0 --- /dev/null +++ b/tests/iot/modules/test_homekit.py @@ -0,0 +1,59 @@ +from unittest.mock import PropertyMock, patch + +import pytest + +from kasa import Module +from kasa.iot import IotDevice +from kasa.iot.modules.homekit import HomeKit + +from ...device_fixtures import device_iot + + +@device_iot +def test_homekit_getters(dev: IotDevice) -> None: + # HomeKit can be present on any IOT device + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + info = homekit.info + if not info: + pytest.skip("No HomeKit data present for this fixture") + assert "setup_code" in info + assert "setup_payload" in info + assert "err_code" in info + # Check that the setup_code and setup_payload are strings + assert isinstance(info["setup_code"], str) + assert isinstance(info["setup_payload"], str) + assert isinstance(info["err_code"], int) + # Check that the HomeKit module properties match + assert info["setup_code"] == homekit.setup_code + assert info["setup_payload"] == homekit.setup_payload + + +@device_iot +def test_homekit_feature(dev: IotDevice) -> None: + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + if not homekit.info: + pytest.skip("No HomeKit data present for this device") + feature = homekit._all_features.get("homekit_setup_code") + assert feature is not None + assert isinstance(feature.attribute_getter, str) + value = getattr(homekit, feature.attribute_getter) + assert value == homekit.setup_code + + +@device_iot +def test_initialize_features_skips_when_no_data(dev: IotDevice) -> None: + if Module.IotHomeKit not in dev.modules: + pytest.skip("HomeKit module not present on this device") + homekit: HomeKit = dev.modules[Module.IotHomeKit] + if "homekit_setup_code" in homekit._all_features: + pytest.skip("HomeKit feature already present on this device") + # Patch .data so it looks like no homekit data is present + with patch.object(HomeKit, "data", new_callable=PropertyMock) as mock_data: + mock_data.return_value = {} + homekit._initialize_features() + # Since there was no data, no features should be added + assert "homekit_setup_code" not in homekit._all_features diff --git a/tests/iot/modules/test_motion.py b/tests/iot/modules/test_motion.py index a2b32a877..e0ed8c2e1 100644 --- a/tests/iot/modules/test_motion.py +++ b/tests/iot/modules/test_motion.py @@ -1,6 +1,7 @@ +import pytest from pytest_mock import MockerFixture -from kasa import Module +from kasa import KasaException, Module from kasa.iot import IotDimmer from kasa.iot.modules.motion import Motion, Range @@ -8,7 +9,7 @@ @dimmer_iot -def test_motion_getters(dev: IotDimmer): +def test_motion_getters(dev: IotDimmer) -> None: assert Module.IotMotion in dev.modules motion: Motion = dev.modules[Module.IotMotion] @@ -18,7 +19,7 @@ def test_motion_getters(dev: IotDimmer): @dimmer_iot -async def test_motion_setters(dev: IotDimmer, mocker: MockerFixture): +async def test_motion_setters(dev: IotDimmer, mocker: MockerFixture) -> None: motion: Motion = dev.modules[Module.IotMotion] query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") @@ -32,25 +33,80 @@ async def test_motion_setters(dev: IotDimmer, mocker: MockerFixture): @dimmer_iot -async def test_motion_range(dev: IotDimmer, mocker: MockerFixture): +async def test_motion_range(dev: IotDimmer, mocker: MockerFixture) -> None: motion: Motion = dev.modules[Module.IotMotion] query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") - await motion.set_range(custom_range=123) - query_helper.assert_called_with( - "smartlife.iot.PIR", - "set_trigger_sens", - {"index": Range.Custom.value, "value": 123}, - ) + for range in Range: + await motion.set_range(range) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) - await motion.set_range(range=Range.Far) - query_helper.assert_called_with( - "smartlife.iot.PIR", "set_trigger_sens", {"index": Range.Far.value} - ) + +@dimmer_iot +async def test_motion_range_from_string(dev: IotDimmer, mocker: MockerFixture) -> None: + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + ranges_good = { + "near": Range.Near, + "MID": Range.Mid, + "fAr": Range.Far, + " Custom ": Range.Custom, + } + for range_str, range in ranges_good.items(): + await motion._set_range_from_str(range_str) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) + + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + ranges_bad = ["near1", "MD", "F\nAR", "Custom Near", '"FAR"', "'FAR'"] + for range_str in ranges_bad: + with pytest.raises(KasaException): + await motion._set_range_from_str(range_str) + query_helper.assert_not_called() + + +@dimmer_iot +async def test_motion_threshold(dev: IotDimmer, mocker: MockerFixture) -> None: + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + for range in Range: + # Switch to a given range. + await motion.set_range(range) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) + + # Assert that the range always goes to custom, regardless of current range. + await motion.set_threshold(123) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": Range.Custom.value, "value": 123}, + ) + + +@dimmer_iot +async def test_motion_realtime(dev: IotDimmer, mocker: MockerFixture) -> None: + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await motion.get_pir_state() + query_helper.assert_called_with("smartlife.iot.PIR", "get_adc_value", None) @dimmer_iot -def test_motion_feature(dev: IotDimmer): +def test_motion_feature(dev: IotDimmer) -> None: assert Module.IotMotion in dev.modules motion: Motion = dev.modules[Module.IotMotion] diff --git a/tests/iot/modules/test_schedule.py b/tests/iot/modules/test_schedule.py index 4a4ffdee6..c65cb36f3 100644 --- a/tests/iot/modules/test_schedule.py +++ b/tests/iot/modules/test_schedule.py @@ -8,7 +8,7 @@ @device_iot @pytest.mark.xdist_group(name="caplog") -def test_schedule(dev: Device, caplog: pytest.LogCaptureFixture): +def test_schedule(dev: Device, caplog: pytest.LogCaptureFixture) -> None: schedule = dev.modules.get(Module.IotSchedule) assert schedule if rules := schedule.rules: diff --git a/tests/test_usage.py b/tests/iot/modules/test_usage.py similarity index 89% rename from tests/test_usage.py rename to tests/iot/modules/test_usage.py index 7b2c0eed6..e180b01b7 100644 --- a/tests/test_usage.py +++ b/tests/iot/modules/test_usage.py @@ -4,10 +4,10 @@ from kasa.iot.modules import Usage -def test_usage_convert_stat_data(): - usage = Usage(None, module="usage") +def test_usage_convert_stat_data() -> None: + usage = Usage(Mock(), module="usage") - test_data = [] + test_data: list[dict[str, int]] = [] assert usage._convert_stat_data(test_data, "day") == {} test_data = [ @@ -24,13 +24,13 @@ def test_usage_convert_stat_data(): assert v == 30 -def test_usage_today(): +def test_usage_today() -> None: """Test fetching the usage for today. This test uses inline data since the fixtures will not have data for the current day. """ - emeter_data = { + emeter_data: dict = { "get_daystat": { "day_list": [], "err_code": 0, @@ -55,13 +55,13 @@ def data(self): assert usage.usage_today == 500 -def test_usage_this_month(): +def test_usage_this_month() -> None: """Test fetching the usage for this month. This test uses inline data since the fixtures will not have data for the current month. """ - emeter_data = { + emeter_data: dict = { "get_monthstat": { "month_list": [], "err_code": 0, diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py new file mode 100644 index 000000000..870efe20f --- /dev/null +++ b/tests/iot/test_iotbulb.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import re + +import pytest +from pytest_mock import MockerFixture +from voluptuous import ( + All, + Boolean, + Optional, + Range, + Schema, +) + +from kasa import Device, IotLightPreset, KasaException, LightState, Module +from kasa.iot import IotBulb, IotDimmer +from kasa.iot.modules import LightPreset as IotLightPresetModule +from tests.conftest import ( + bulb_iot, + color_bulb_iot, + dimmable_iot, + handle_turn_on, + non_dimmable_iot, + turn_on, + variable_temp_iot, +) +from tests.iot.test_iotdevice import SYSINFO_SCHEMA + + +@bulb_iot +async def test_bulb_sysinfo(dev: Device) -> None: + assert dev.sys_info is not None + SYSINFO_SCHEMA_BULB(dev.sys_info) + + assert dev.model is not None + + +@bulb_iot +async def test_light_state_without_update( + dev: IotBulb, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) + with pytest.raises(KasaException): + print(dev.light_state) + + +@bulb_iot +async def test_get_light_state(dev: IotBulb) -> None: + LIGHT_STATE_SCHEMA(await dev.get_light_state()) + + +@color_bulb_iot +async def test_set_hsv_transition(dev: IotBulb, mocker: MockerFixture) -> None: + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_hsv(10, 10, 100, transition=1000) + + set_light_state.assert_called_with( + {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, + transition=1000, + ) + + +@bulb_iot +async def test_light_set_state(dev: IotBulb, mocker: MockerFixture) -> None: + """Testing setting LightState on the light module.""" + light = dev.modules.get(Module.Light) + assert light + set_light_state = mocker.spy(dev, "_set_light_state") + state = LightState(light_on=True) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 1}, transition=None) + state = LightState(light_on=False) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 0}, transition=None) + + +@variable_temp_iot +async def test_set_color_temp_transition(dev: IotBulb, mocker: MockerFixture) -> None: + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_color_temp(2700, transition=100) + + set_light_state.assert_called_with({"color_temp": 2700}, transition=100) + + +@variable_temp_iot +@pytest.mark.xdist_group(name="caplog") +async def test_unknown_temp_range( + dev: IotBulb, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") + light = dev.modules.get(Module.Light) + assert light + color_temp_feat = light.get_feature("color_temp") + assert color_temp_feat + assert color_temp_feat.range == (2700, 5000) + assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text + + +@dimmable_iot +@turn_on +async def test_dimmable_brightness(dev: IotBulb, turn_on: bool) -> None: + assert isinstance(dev, IotBulb | IotDimmer) + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + assert dev._is_dimmable + + await light.set_brightness(50) + await dev.update() + assert light.brightness == 50 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(TypeError, match="Brightness must be an integer"): + await light.set_brightness("foo") # type: ignore[arg-type] + + +@bulb_iot +async def test_turn_on_transition(dev: IotBulb, mocker: MockerFixture) -> None: + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + await dev.turn_on(transition=1000) + + set_light_state.assert_called_with({"on_off": 1}, transition=1000) + + await dev.turn_off(transition=100) + + set_light_state.assert_called_with({"on_off": 0}, transition=100) + + +@bulb_iot +async def test_dimmable_brightness_transition( + dev: IotBulb, mocker: MockerFixture +) -> None: + set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") + light = dev.modules.get(Module.Light) + assert light + await light.set_brightness(10, transition=1000) + + set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) + + +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb) -> None: + assert dev._is_dimmable + light = dev.modules.get(Module.Light) + assert light + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), + ): + await light.set_brightness(110) + + with pytest.raises( + ValueError, + match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), + ): + await light.set_brightness(-100) + + +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb) -> None: + assert not dev._is_dimmable + light = dev.modules.get(Module.Light) + assert light + with pytest.raises(KasaException): + assert light.brightness == 0 + with pytest.raises(KasaException): + await light.set_brightness(100) + + +@bulb_iot +async def test_ignore_default_not_set_without_color_mode_change_turn_on( + dev: IotBulb, mocker: MockerFixture +) -> None: + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") + # When turning back without settings, ignore default to restore the state + await dev.turn_on() + args, kwargs = query_helper.call_args_list[0] + assert args[2] == {"on_off": 1, "ignore_default": 0} + + await dev.turn_off() + args, kwargs = query_helper.call_args_list[1] + assert args[2] == {"on_off": 0, "ignore_default": 1} + + +@bulb_iot +async def test_list_presets(dev: IotBulb) -> None: + light_preset = dev.modules.get(Module.LightPreset) + assert light_preset + assert isinstance(light_preset, IotLightPresetModule) + presets = light_preset._deprecated_presets + # Light strip devices may list some light effects along with normal presets but these + # are handled by the LightEffect module so exclude preferred states with id + raw_presets = [ + pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate + ] + assert len(presets) == len(raw_presets) + + for preset, raw in zip(presets, raw_presets, strict=False): + assert preset.index == raw["index"] + assert preset.brightness == raw["brightness"] + assert preset.hue == raw["hue"] + assert preset.saturation == raw["saturation"] + assert preset.color_temp == raw["color_temp"] + + +@bulb_iot +async def test_modify_preset(dev: IotBulb, mocker: MockerFixture) -> None: + """Verify that modifying preset calls the and exceptions are raised properly.""" + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): + pytest.skip("Some strips do not support presets") + + assert isinstance(light_preset, IotLightPresetModule) + data: dict[str, int | None] = { + "index": 0, + "brightness": 10, + "hue": 0, + "saturation": 0, + "color_temp": 0, + } + preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] + + assert preset.index == 0 + assert preset.brightness == 10 + assert preset.hue == 0 + assert preset.saturation == 0 + assert preset.color_temp == 0 + + await light_preset._deprecated_save_preset(preset) + await dev.update() + assert light_preset._deprecated_presets[0].brightness == 10 + + with pytest.raises(KasaException): + await light_preset._deprecated_save_preset( + IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] + ) + + +@bulb_iot +@pytest.mark.parametrize( + ("preset", "payload"), + [ + ( + IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] + {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, + ), + ( + IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] + {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, + ), + ], +) +async def test_modify_preset_payloads( + dev: IotBulb, preset, payload, mocker: MockerFixture +) -> None: + """Test that modify preset payloads ignore none values.""" + if ( + not (light_preset := dev.modules.get(Module.LightPreset)) + or not light_preset._deprecated_presets + ): + pytest.skip("Some strips do not support presets") + + query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") + await light_preset._deprecated_save_preset(preset) + query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) + + +LIGHT_STATE_SCHEMA = Schema( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "on_off": Boolean, + "saturation": All(int, Range(min=0, max=100)), + "length": Optional(int), + "transition": Optional(int), + "groups": Optional(list[int]), + "dft_on_state": Optional( + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": All(int, Range(min=0, max=9000)), + "hue": All(int, Range(min=0, max=360)), + "mode": str, + "saturation": All(int, Range(min=0, max=100)), + "groups": Optional(list[int]), + } + ), + "err_code": int, + } +) + +SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( + { + "ctrl_protocols": Optional(dict), + "description": Optional(str), # Seen on LBxxx, similar to dev_name + "dev_state": str, + "disco_ver": str, + "heapsize": int, + "is_color": Boolean, + "is_dimmable": Boolean, + "is_factory": Boolean, + "is_variable_color_temp": Boolean, + "light_state": LIGHT_STATE_SCHEMA, + "preferred_state": [ + { + "brightness": All(int, Range(min=0, max=100)), + "color_temp": int, + "hue": All(int, Range(min=0, max=360)), + "index": int, + "saturation": All(int, Range(min=0, max=100)), + } + ], + } +) + + +@bulb_iot +async def test_turn_on_behaviours(dev: IotBulb) -> None: + behavior = await dev.get_turn_on_behavior() + assert behavior diff --git a/tests/test_iotdevice.py b/tests/iot/test_iotdevice.py similarity index 80% rename from tests/test_iotdevice.py rename to tests/iot/test_iotdevice.py index 68ee7a51a..19625a641 100644 --- a/tests/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -4,6 +4,7 @@ from datetime import datetime import pytest +from pytest_mock import MockerFixture from voluptuous import ( REMOVE_EXTRA, All, @@ -19,10 +20,9 @@ from kasa import DeviceType, KasaException, Module from kasa.iot import IotDevice from kasa.iot.iotmodule import _merge_dict - -from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on -from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot -from .fakeprotocol_iot import FakeIotProtocol +from tests.conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on +from tests.device_fixtures import device_iot, has_emeter_iot, no_emeter_iot +from tests.fakeprotocol_iot import FakeIotProtocol TZ_SCHEMA = Schema( {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} @@ -85,22 +85,22 @@ def check_mac(x): @device_iot -async def test_state_info(dev): +async def test_state_info(dev: IotDevice) -> None: assert isinstance(dev.state_information, dict) @pytest.mark.requires_dummy @device_iot -async def test_invalid_connection(mocker, dev): +async def test_invalid_connection(mocker: MockerFixture, dev: IotDevice) -> None: mocker.patch.object(FakeIotProtocol, "query", side_effect=KasaException) with pytest.raises(KasaException): await dev.update() @has_emeter_iot -async def test_initial_update_emeter(dev, mocker): +async def test_initial_update_emeter(dev: IotDevice, mocker: MockerFixture) -> None: """Test that the initial update performs second query if emeter is available.""" - dev._last_update = None + dev._last_update = {} dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() @@ -110,9 +110,9 @@ async def test_initial_update_emeter(dev, mocker): @no_emeter_iot -async def test_initial_update_no_emeter(dev, mocker): +async def test_initial_update_no_emeter(dev: IotDevice, mocker: MockerFixture) -> None: """Test that the initial update performs second query if emeter is available.""" - dev._last_update = None + dev._last_update = {} dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() @@ -129,7 +129,7 @@ async def test_initial_update_no_emeter(dev, mocker): @device_iot -async def test_query_helper(dev): +async def test_query_helper(dev: IotDevice) -> None: with pytest.raises(KasaException): await dev._query_helper("test", "testcmd", {}) # TODO check for unwrapping? @@ -137,9 +137,10 @@ async def test_query_helper(dev): @device_iot @turn_on -async def test_state(dev, turn_on): - await handle_turn_on(dev, turn_on) +async def test_state(dev: IotDevice, turn_on: bool) -> None: orig_state = dev.is_on + await handle_turn_on(dev, turn_on) + await dev.update() if orig_state: await dev.turn_off() await dev.update() @@ -164,7 +165,7 @@ async def test_state(dev, turn_on): @device_iot @turn_on -async def test_on_since(dev, turn_on): +async def test_on_since(dev: IotDevice, turn_on: bool) -> None: await handle_turn_on(dev, turn_on) orig_state = dev.is_on if "on_time" not in dev.sys_info and dev.device_type is not DeviceType.Strip: @@ -176,43 +177,43 @@ async def test_on_since(dev, turn_on): @device_iot -async def test_time(dev): +async def test_time(dev: IotDevice) -> None: assert isinstance(dev.modules[Module.Time].time, datetime) @device_iot -async def test_timezone(dev): +async def test_timezone(dev: IotDevice) -> None: TZ_SCHEMA(await dev.modules[Module.Time].get_timezone()) @device_iot -async def test_hw_info(dev): +async def test_hw_info(dev: IotDevice) -> None: SYSINFO_SCHEMA(dev.hw_info) @device_iot -async def test_location(dev): +async def test_location(dev: IotDevice) -> None: SYSINFO_SCHEMA(dev.location) @device_iot -async def test_rssi(dev): +async def test_rssi(dev: IotDevice) -> None: SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol @device_iot -async def test_mac(dev): +async def test_mac(dev: IotDevice) -> None: SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val @device_iot -async def test_representation(dev): +async def test_representation(dev: IotDevice) -> None: pattern = re.compile(r"") assert pattern.match(str(dev)) @device_iot -async def test_children(dev): +async def test_children(dev: IotDevice) -> None: """Make sure that children property is exposed by every device.""" if dev.device_type is DeviceType.Strip: assert len(dev.children) > 0 @@ -221,7 +222,7 @@ async def test_children(dev): @device_iot -async def test_modules_preserved(dev: IotDevice): +async def test_modules_preserved(dev: IotDevice) -> None: """Make modules that are not being updated are preserved between updates.""" dev._last_update["some_module_not_being_updated"] = "should_be_kept" await dev.update() @@ -229,13 +230,13 @@ async def test_modules_preserved(dev: IotDevice): @device_iot -async def test_internal_state(dev): +async def test_internal_state(dev: IotDevice) -> None: """Make sure the internal state returns the last update results.""" assert dev.internal_state == dev._last_update @device_iot -async def test_features(dev): +async def test_features(dev: IotDevice) -> None: """Make sure features is always accessible.""" sysinfo = dev._last_update["system"]["get_sysinfo"] if "feature" in sysinfo: @@ -245,20 +246,20 @@ async def test_features(dev): @device_iot -async def test_max_device_response_size(dev): +async def test_max_device_response_size(dev: IotDevice) -> None: """Make sure every device return has a set max response size.""" assert dev.max_device_response_size > 0 @device_iot -async def test_estimated_response_sizes(dev): +async def test_estimated_response_sizes(dev: IotDevice) -> None: """Make sure every module has an estimated response size set.""" for mod in dev.modules.values(): assert mod.estimated_query_response_size > 0 @device_iot -async def test_modules_not_supported(dev: IotDevice): +async def test_modules_not_supported(dev: IotDevice) -> None: """Test that unsupported modules do not break the device.""" for module in dev.modules.values(): assert module.is_supported is not None @@ -267,7 +268,7 @@ async def test_modules_not_supported(dev: IotDevice): assert module.is_supported is not None -async def test_get_modules(): +async def test_get_modules() -> None: """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "HS100(US)_2.0_1.5.6.json", "IOT" @@ -277,12 +278,12 @@ async def test_get_modules(): # Modules on device module = dummy_device.modules.get("cloud") assert module - assert module._device == dummy_device + assert module.device == dummy_device assert isinstance(module, Cloud) module = dummy_device.modules.get(Module.IotCloud) assert module - assert module._device == dummy_device + assert module.device == dummy_device assert isinstance(module, Cloud) # Invalid modules @@ -293,10 +294,10 @@ async def test_get_modules(): assert module is None -def test_merge_dict(): +def test_merge_dict() -> None: """Test the recursive dict merge.""" - dest = {"a": 1, "b": {"c": 2, "d": 3}} - source = {"b": {"c": 4, "e": 5}} + dest: dict = {"a": 1, "b": {"c": 2, "d": 3}} + source: dict = {"b": {"c": 4, "e": 5}} assert _merge_dict(dest, source) == {"a": 1, "b": {"c": 4, "d": 3, "e": 5}} dest = {"smartlife.iot.common.emeter": {"get_realtime": None}} diff --git a/tests/test_dimmer.py b/tests/iot/test_iotdimmer.py similarity index 80% rename from tests/test_dimmer.py rename to tests/iot/test_iotdimmer.py index 3505a7c1c..561796d4a 100644 --- a/tests/test_dimmer.py +++ b/tests/iot/test_iotdimmer.py @@ -1,13 +1,13 @@ import pytest +from pytest_mock import MockerFixture from kasa import DeviceType, Module from kasa.iot import IotDimmer - -from .conftest import dimmer_iot, handle_turn_on, turn_on +from tests.conftest import dimmer_iot, handle_turn_on, turn_on @dimmer_iot -async def test_set_brightness(dev): +async def test_set_brightness(dev: IotDimmer) -> None: light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, False) @@ -27,7 +27,9 @@ async def test_set_brightness(dev): @dimmer_iot @turn_on -async def test_set_brightness_transition(dev, turn_on, mocker): +async def test_set_brightness_transition( + dev: IotDimmer, turn_on: bool, mocker: MockerFixture +) -> None: light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) @@ -50,7 +52,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker): @dimmer_iot -async def test_set_brightness_invalid(dev): +async def test_set_brightness_invalid(dev: IotDimmer) -> None: light = dev.modules.get(Module.Light) assert light for invalid_brightness in [-1, 101]: @@ -63,7 +65,7 @@ async def test_set_brightness_invalid(dev): @dimmer_iot -async def test_set_brightness_invalid_transition(dev): +async def test_set_brightness_invalid_transition(dev: IotDimmer) -> None: light = dev.modules.get(Module.Light) assert light for invalid_transition in [-1]: @@ -75,7 +77,7 @@ async def test_set_brightness_invalid_transition(dev): @dimmer_iot -async def test_turn_on_transition(dev, mocker): +async def test_turn_on_transition(dev: IotDimmer, mocker: MockerFixture) -> None: light = dev.modules.get(Module.Light) assert light query_helper = mocker.spy(IotDimmer, "_query_helper") @@ -94,7 +96,7 @@ async def test_turn_on_transition(dev, mocker): @dimmer_iot -async def test_turn_off_transition(dev, mocker): +async def test_turn_off_transition(dev: IotDimmer, mocker: MockerFixture) -> None: light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, True) @@ -116,7 +118,9 @@ async def test_turn_off_transition(dev, mocker): @dimmer_iot @turn_on -async def test_set_dimmer_transition(dev, turn_on, mocker): +async def test_set_dimmer_transition( + dev: IotDimmer, turn_on: bool, mocker: MockerFixture +) -> None: light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) @@ -136,7 +140,9 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): @dimmer_iot @turn_on -async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): +async def test_set_dimmer_transition_to_off( + dev: IotDimmer, turn_on: bool, mocker: MockerFixture +) -> None: light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) @@ -157,26 +163,26 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): @dimmer_iot -async def test_set_dimmer_transition_invalid_brightness(dev): +async def test_set_dimmer_transition_invalid_brightness(dev: IotDimmer) -> None: for invalid_brightness in [-1, 101]: with pytest.raises(ValueError, match="Invalid brightness value: "): await dev.set_dimmer_transition(invalid_brightness, 1000) for invalid_type in [0.5, "foo"]: with pytest.raises(TypeError, match="Transition must be integer"): - await dev.set_dimmer_transition(1, invalid_type) + await dev.set_dimmer_transition(1, invalid_type) # type: ignore[arg-type] @dimmer_iot -async def test_set_dimmer_transition_invalid_transition(dev): +async def test_set_dimmer_transition_invalid_transition(dev: IotDimmer) -> None: for invalid_transition in [-1]: with pytest.raises(ValueError, match="Transition value .+? is not valid."): await dev.set_dimmer_transition(1, transition=invalid_transition) for invalid_type in [0.5, "foo"]: with pytest.raises(TypeError, match="Transition must be integer"): - await dev.set_dimmer_transition(1, transition=invalid_type) + await dev.set_dimmer_transition(1, transition=invalid_type) # type: ignore[arg-type] @dimmer_iot -def test_device_type_dimmer(dev): +def test_device_type_dimmer(dev: IotDimmer) -> None: assert dev.device_type == DeviceType.Dimmer diff --git a/tests/test_lightstrip.py b/tests/iot/test_iotlightstrip.py similarity index 83% rename from tests/test_lightstrip.py rename to tests/iot/test_iotlightstrip.py index 365d0163d..c78780f82 100644 --- a/tests/test_lightstrip.py +++ b/tests/iot/test_iotlightstrip.py @@ -1,20 +1,20 @@ import pytest +from pytest_mock import MockerFixture from kasa import DeviceType, Module from kasa.iot import IotLightStrip from kasa.iot.modules import LightEffect - -from .conftest import lightstrip_iot +from tests.conftest import lightstrip_iot @lightstrip_iot -async def test_lightstrip_length(dev: IotLightStrip): +async def test_lightstrip_length(dev: IotLightStrip) -> None: assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] @lightstrip_iot -async def test_lightstrip_effect(dev: IotLightStrip): +async def test_lightstrip_effect(dev: IotLightStrip) -> None: le: LightEffect = dev.modules[Module.LightEffect] assert isinstance(le._deprecated_effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: @@ -22,7 +22,7 @@ async def test_lightstrip_effect(dev: IotLightStrip): @lightstrip_iot -async def test_effects_lightstrip_set_effect(dev: IotLightStrip): +async def test_effects_lightstrip_set_effect(dev: IotLightStrip) -> None: le: LightEffect = dev.modules[Module.LightEffect] with pytest.raises( ValueError, match="The effect Not real is not a built in effect" @@ -37,8 +37,8 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): @lightstrip_iot @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( - dev: IotLightStrip, brightness, mocker -): + dev: IotLightStrip, brightness: int, mocker: MockerFixture +) -> None: query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") le: LightEffect = dev.modules[Module.LightEffect] @@ -56,8 +56,8 @@ async def test_effects_lightstrip_set_effect_brightness( @lightstrip_iot @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( - dev: IotLightStrip, transition, mocker -): + dev: IotLightStrip, transition: int, mocker: MockerFixture +) -> None: query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") le: LightEffect = dev.modules[Module.LightEffect] @@ -73,12 +73,12 @@ async def test_effects_lightstrip_set_effect_transition( @lightstrip_iot -async def test_effects_lightstrip_has_effects(dev: IotLightStrip): +async def test_effects_lightstrip_has_effects(dev: IotLightStrip) -> None: le: LightEffect = dev.modules[Module.LightEffect] assert le is not None assert le.effect_list @lightstrip_iot -def test_device_type_lightstrip(dev): +def test_device_type_lightstrip(dev: IotLightStrip) -> None: assert dev.device_type == DeviceType.LightStrip diff --git a/tests/iot/test_iotstrip.py b/tests/iot/test_iotstrip.py new file mode 100644 index 000000000..1ef32c29a --- /dev/null +++ b/tests/iot/test_iotstrip.py @@ -0,0 +1,49 @@ +from unittest.mock import AsyncMock + +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.iot import IotStrip +from kasa.iot.iotstrip import IotStripPlug +from tests.conftest import strip_emeter_iot, strip_iot + + +@strip_iot +async def test_strip_update_and_child_update_behaviors(dev: IotStrip) -> None: + await dev.update() + await dev.update(update_children=False) + + assert dev.children, "Expected strip device to have children" + + child = dev.children[0] + await child.update(update_children=False) + + assert getattr(child, "_features", None) + + +@strip_iot +async def test_strip_child_delegated_properties(dev: IotStrip) -> None: + await dev.update() + child = dev.children[0] + assert isinstance(child, IotStripPlug) + + assert child.led is False + assert child.time == dev.time + assert child.timezone == dev.timezone + + na = child.next_action + assert isinstance(na, dict) + assert "type" in na + + +@strip_emeter_iot +async def test_strip_emeter_erase_stats(dev: IotStrip, mocker: MockerFixture) -> None: + await dev.update() + + for child in dev.children: + energy = child.modules.get(Module.Energy) + if energy: + mocker.patch.object(energy, "erase_stats", AsyncMock(return_value={})) + + res = await dev.modules[Module.Energy].erase_stats() + assert res == {} diff --git a/tests/iot/test_iottimezone.py b/tests/iot/test_iottimezone.py new file mode 100644 index 000000000..ae2f24f0d --- /dev/null +++ b/tests/iot/test_iottimezone.py @@ -0,0 +1,194 @@ +from datetime import UTC, datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import pytest +from pytest_mock import MockerFixture + + +def test_expected_dst_behavior_for_index_cases() -> None: + """Exercise _expected_dst_behavior_for_index for several representative indices.""" + from kasa.iot.iottimezone import _expected_dst_behavior_for_index + + # Posix-style DST zones + assert _expected_dst_behavior_for_index(10) is True # MST7MDT + assert _expected_dst_behavior_for_index(13) is True # CST6CDT + # Fixed-offset or fixed-abbreviation zones + assert _expected_dst_behavior_for_index(34) is False # Etc/GMT+2 + assert _expected_dst_behavior_for_index(18) is False # EST + # Invalid index should raise KeyError + with pytest.raises(KeyError): + _expected_dst_behavior_for_index(999) + + +async def test_guess_timezone_by_offset_fixed_fallback_unit() -> None: + """When no ZoneInfo matches, return a fixed-offset tzinfo.""" + import kasa.iot.iottimezone as tzmod + + year = datetime.now(UTC).year + when = datetime(year, 1, 15, 12, tzinfo=UTC) + offset = timedelta(minutes=2) # unlikely to match any real zone + tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when) + assert tz.utcoffset(when) == offset + + +async def test_guess_timezone_by_offset_candidates_unit() -> None: + """Cover naive when_utc branch and candidate selection path (non-empty candidates).""" + import kasa.iot.iottimezone as tzmod + + # naive datetime hits the 'naive -> UTC' branch + when = datetime(2025, 1, 15, 12) + offset = timedelta(0) + tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when) + + # Should choose a ZoneInfo candidate (not the fixed-offset fallback), with matching offset + assert isinstance(tz, ZoneInfo) + assert tz.utcoffset(when.replace(tzinfo=UTC)) == offset + + +async def test_guess_timezone_by_offset_dst_expected_true_filters( + mocker: MockerFixture, +) -> None: + """dst_expected=True should prefer a DST-observing zone when possible.""" + import kasa.iot.iottimezone as tzmod + + when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC) + tz = await tzmod._guess_timezone_by_offset( + timedelta(0), when_utc=when, dst_expected=True + ) + assert tz.utcoffset(when) == timedelta(0) + if isinstance(tz, ZoneInfo): + jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + assert jan != jul # observes DST + + +async def test_guess_timezone_by_offset_dst_expected_false_prefers_non_dst() -> None: + """dst_expected=False should prefer a non-DST zone and skip DST candidates (covers False branch).""" + import kasa.iot.iottimezone as tzmod + + when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC) + tz = await tzmod._guess_timezone_by_offset( + timedelta(0), when_utc=when, dst_expected=False + ) + assert tz.utcoffset(when) == timedelta(0) + if isinstance(tz, ZoneInfo): + jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset() + assert jan == jul # non-DST zone chosen + + +async def test_guess_timezone_by_offset_handles_missing_zoneinfo_unit( + mocker: MockerFixture, +) -> None: + """Cover the ZoneInfoNotFoundError continue path within guess_timezone_by_offset.""" + from zoneinfo import ZoneInfoNotFoundError as ZNF + + import kasa.iot.iottimezone as tzmod + + original = tzmod.CachedZoneInfo.get_cached_zone_info + + async def flaky_get(name: str): + # Force the first entry to raise to exercise the except path (143-144) + first_name = next(iter(tzmod.TIMEZONE_INDEX.values())) + if name == first_name: + raise ZNF("unavailable on host") + return await original(name) + + mocker.patch.object(tzmod.CachedZoneInfo, "get_cached_zone_info", new=flaky_get) + + when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC) + tz = await tzmod._guess_timezone_by_offset(timedelta(0), when_utc=when) + assert tz.utcoffset(when) == timedelta(0) + + +async def test_get_timezone_index_direct_match() -> None: + """If ZoneInfo key is in TIMEZONE_INDEX, return index directly.""" + import kasa.iot.iottimezone as tzmod + + idx = await tzmod.get_timezone_index(ZoneInfo("GB")) + assert idx == 39 # "GB" is mapped to index 39 + + +async def test_get_timezone_index_non_zoneinfo_unit() -> None: + """Exercise get_timezone_index path when input tzinfo is not a ZoneInfo instance.""" + import kasa.iot.iottimezone as tzmod + + # Fixed offset +0 should match a valid index (e.g., UCT/Africa/Monrovia) + idx = await tzmod.get_timezone_index(timezone(timedelta(0))) + assert isinstance(idx, int) + assert 0 <= idx <= 109 + + +async def test_get_timezone_index_skips_missing_unit(mocker: MockerFixture) -> None: + """Cover ZoneInfoNotFoundError path in get_timezone_index loop and successful match.""" + from zoneinfo import ZoneInfoNotFoundError as ZNF + + import kasa.iot.iottimezone as tzmod + + original_get_tz = tzmod.get_timezone + + async def side_effect(i: int): + if i < 5: + raise ZNF("unavailable on host") + return await original_get_tz(i) + + mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect) + + # Use a ZoneInfo not directly present in TIMEZONE_INDEX values to avoid early return + idx = await tzmod.get_timezone_index(ZoneInfo("Europe/London")) + assert isinstance(idx, int) + assert 0 <= idx <= 109 + assert idx >= 5 + + +async def test_get_timezone_index_raises_for_unmatched_unit() -> None: + """Ensure get_timezone_index completes loop and raises when no match exists (covers raise branch).""" + import kasa.iot.iottimezone as tzmod + + # Uncommon 2-minute offset won't match any real zone in TIMEZONE_INDEX + with pytest.raises(ValueError, match="Device does not support timezone"): + await tzmod.get_timezone_index(timezone(timedelta(minutes=2))) + + +async def test_get_matching_timezones_branches_unit(mocker: MockerFixture) -> None: + """Cover initial append, except path, and duplicate suppression in get_matching_timezones.""" + from zoneinfo import ZoneInfoNotFoundError as ZNF + + import kasa.iot.iottimezone as tzmod + + original_get_tz = tzmod.get_timezone + + async def side_effect(i: int): + # Force one miss to hit the except path + if i == 0: + raise ZNF("unavailable on host") + return await original_get_tz(i) + + mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect) + + # 'GB' is in TIMEZONE_INDEX; passing ZoneInfo('GB') will trigger initial append + matches = await tzmod.get_matching_timezones(ZoneInfo("GB")) + assert "GB" in matches # initial append done + # Loop should find GB again but not duplicate it + + +async def test_get_matching_timezones_non_zoneinfo_unit() -> None: + """Exercise get_matching_timezones when input tzinfo is not a ZoneInfo (skips initial append).""" + import kasa.iot.iottimezone as tzmod + + matches = await tzmod.get_matching_timezones(timezone(timedelta(0))) + assert isinstance(matches, list) + assert len(matches) > 0 + + +async def test_get_timezone_out_of_range_defaults_to_utc() -> None: + """Out-of-range index should log and default to UTC.""" + import kasa.iot.iottimezone as tzmod + + tz = await tzmod.get_timezone(-1) + assert isinstance(tz, ZoneInfo) + assert tz.key in ("Etc/UTC", "UTC") # platform alias acceptable + + tz2 = await tzmod.get_timezone(999) + assert isinstance(tz2, ZoneInfo) + assert tz2.key in ("Etc/UTC", "UTC") diff --git a/tests/iot/test_wallswitch.py b/tests/iot/test_wallswitch.py index b6fd2a673..3e2eac428 100644 --- a/tests/iot/test_wallswitch.py +++ b/tests/iot/test_wallswitch.py @@ -1,8 +1,10 @@ +from kasa.iot import IotWallSwitch + from ..device_fixtures import wallswitch_iot @wallswitch_iot -def test_wallswitch_motion(dev): +def test_wallswitch_motion(dev: IotWallSwitch) -> None: """Check that wallswitches with motion sensor get modules enabled.""" has_motion = "PIR" in dev.sys_info["dev_name"] assert "motion" in dev.modules if has_motion else True diff --git a/tests/protocols/__init__.py b/tests/protocols/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_protocol.py b/tests/protocols/test_iotprotocol.py similarity index 66% rename from tests/test_protocol.py rename to tests/protocols/test_iotprotocol.py index 09134e851..8842b5510 100644 --- a/tests/test_protocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import errno import importlib @@ -12,11 +14,12 @@ from unittest.mock import AsyncMock import pytest +from pytest_mock import MockerFixture from kasa.credentials import Credentials from kasa.device import Device from kasa.deviceconfig import DeviceConfig -from kasa.exceptions import KasaException +from kasa.exceptions import KasaException, TimeoutError from kasa.iot import IotDevice from kasa.protocols.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol from kasa.protocols.protocol import ( @@ -29,8 +32,8 @@ from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 from kasa.transports.xortransport import XorEncryption, XorTransport -from .conftest import device_iot -from .fakeprotocol_iot import FakeIotTransport +from ..conftest import device_iot +from ..fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( @@ -42,8 +45,13 @@ ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class): - def aio_mock_writer(_, __): +async def test_protocol_retries( + mocker: MockerFixture, + retry_count: int, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], +) -> None: + def aio_mock_writer(_: object, __: object): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") @@ -72,8 +80,10 @@ def aio_mock_writer(_, __): ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_no_retry_on_unreachable( - mocker, protocol_class, transport_class -): + mocker: MockerFixture, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], +) -> None: conn = mocker.patch( "asyncio.open_connection", side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), @@ -96,8 +106,10 @@ async def test_protocol_no_retry_on_unreachable( ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_no_retry_connection_refused( - mocker, protocol_class, transport_class -): + mocker: MockerFixture, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], +) -> None: conn = mocker.patch( "asyncio.open_connection", side_effect=ConnectionRefusedError, @@ -120,8 +132,10 @@ async def test_protocol_no_retry_connection_refused( ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_retry_recoverable_error( - mocker, protocol_class, transport_class -): + mocker: MockerFixture, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], +) -> None: conn = mocker.patch( "asyncio.open_connection", side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), @@ -149,20 +163,24 @@ async def test_protocol_retry_recoverable_error( ) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_reconnect( - mocker, retry_count, protocol_class, transport_class, encryption_class -): + mocker: MockerFixture, + retry_count: int, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], + encryption_class, +) -> None: remaining = retry_count encrypted = encryption_class.encrypt('{"great":"success"}')[ transport_class.BLOCK_SIZE : ] - def _fail_one_less_than_retry_count(*_): + def _fail_one_less_than_retry_count(*_) -> None: nonlocal remaining remaining -= 1 if remaining: raise Exception("Simulated write failure") - async def _mock_read(byte_count): + async def _mock_read(byte_count: int): nonlocal encrypted if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) @@ -171,7 +189,7 @@ async def _mock_read(byte_count): raise ValueError(f"No mock for {byte_count}") - def aio_mock_writer(_, __): + def aio_mock_writer(_: object, __: object): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(writer, "write", _fail_one_less_than_retry_count) @@ -199,20 +217,23 @@ def aio_mock_writer(_, __): ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_handles_cancellation_during_write( - mocker, protocol_class, transport_class, encryption_class -): + mocker: MockerFixture, + protocol_class, + transport_class: type[XorTransport], + encryption_class, +) -> None: attempts = 0 encrypted = encryption_class.encrypt('{"great":"success"}')[ transport_class.BLOCK_SIZE : ] - def _cancel_first_attempt(*_): + def _cancel_first_attempt(*_) -> None: nonlocal attempts attempts += 1 if attempts == 1: raise asyncio.CancelledError("Simulated task cancel") - async def _mock_read(byte_count): + async def _mock_read(byte_count: int): nonlocal encrypted if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) @@ -221,7 +242,7 @@ async def _mock_read(byte_count): raise ValueError(f"No mock for {byte_count}") - def aio_mock_writer(_, __): + def aio_mock_writer(_: object, __: object): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(writer, "write", _cancel_first_attempt) @@ -254,14 +275,17 @@ def aio_mock_writer(_, __): ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_handles_cancellation_during_connection( - mocker, protocol_class, transport_class, encryption_class -): + mocker: MockerFixture, + protocol_class, + transport_class: type[XorTransport], + encryption_class, +) -> None: attempts = 0 encrypted = encryption_class.encrypt('{"great":"success"}')[ transport_class.BLOCK_SIZE : ] - async def _mock_read(byte_count): + async def _mock_read(byte_count: int): nonlocal encrypted if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) @@ -270,7 +294,7 @@ async def _mock_read(byte_count): raise ValueError(f"No mock for {byte_count}") - def aio_mock_writer(_, __): + def aio_mock_writer(_: object, __: object): nonlocal attempts attempts += 1 if attempts == 1: @@ -294,6 +318,222 @@ def aio_mock_writer(_, __): assert response == {"great": "success"} +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_during_write( + mocker: MockerFixture, + protocol_class, + transport_class: type[XorTransport], + encryption_class, +) -> None: + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _timeout_first_attempt(*_) -> None: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise TimeoutError("Simulated timeout") + + async def _mock_read(byte_count: int): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_: object, __: object): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _timeout_first_attempt) + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is not None + response = await protocol.query({}) + assert response == {"great": "success"} + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_during_connection( + mocker: MockerFixture, + protocol_class, + transport_class: type[XorTransport], + encryption_class, +) -> None: + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count: int): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_: object, __: object): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise TimeoutError("Simulated timeout") + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + await writer_obj.close() + + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + await protocol.query({"any": "thing"}) + + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is not None + response = await protocol.query({}) + assert response == {"great": "success"} + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_failure_during_write( + mocker: MockerFixture, + protocol_class, + transport_class: type[XorTransport], + encryption_class, +) -> None: + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _timeout_all_attempts(*_) -> None: + raise TimeoutError("Simulated timeout") + + async def _mock_read(byte_count: int): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_: object, __: object): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _timeout_all_attempts) + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises( + TimeoutError, + match="Timeout after 5 seconds sending request to the device 127.0.0.1:9999: Simulated timeout", + ): + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_handles_timeout_failure_during_connection( + mocker: MockerFixture, + protocol_class, + transport_class: type[XorTransport], + encryption_class, +) -> None: + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count: int): + nonlocal encrypted + if byte_count == transport_class.BLOCK_SIZE: + return struct.pack(">I", len(encrypted)) + if byte_count == len(encrypted): + return encrypted + + raise ValueError(f"No mock for {byte_count}") + + def aio_mock_writer(_: object, __: object): + raise TimeoutError("Simulated timeout") + + config = DeviceConfig("127.0.0.1") + protocol = protocol_class(transport=transport_class(config=config)) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + await writer_obj.close() + + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises( + TimeoutError, + match="Timeout after 5 seconds connecting to the device: 127.0.0.1:9999: Simulated timeout", + ): + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + + @pytest.mark.parametrize( ("protocol_class", "transport_class", "encryption_class"), [ @@ -309,15 +549,20 @@ def aio_mock_writer(_, __): @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) @pytest.mark.xdist_group(name="caplog") async def test_protocol_logging( - mocker, caplog, log_level, protocol_class, transport_class, encryption_class -): + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + log_level: int, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], + encryption_class, +) -> None: caplog.set_level(log_level) logging.getLogger("kasa").setLevel(log_level) encrypted = encryption_class.encrypt('{"great":"success"}')[ transport_class.BLOCK_SIZE : ] - async def _mock_read(byte_count): + async def _mock_read(byte_count: int): nonlocal encrypted if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) @@ -325,7 +570,7 @@ async def _mock_read(byte_count): return encrypted raise ValueError(f"No mock for {byte_count}") - def aio_mock_writer(_, __): + def aio_mock_writer(_: object, __: object): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") mocker.patch.object(reader, "readexactly", _mock_read) @@ -357,13 +602,17 @@ def aio_mock_writer(_, __): ) @pytest.mark.parametrize("custom_port", [123, None]) async def test_protocol_custom_port( - mocker, custom_port, protocol_class, transport_class, encryption_class -): + mocker: MockerFixture, + custom_port: int | None, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], + encryption_class, +) -> None: encrypted = encryption_class.encrypt('{"great":"success"}')[ transport_class.BLOCK_SIZE : ] - async def _mock_read(byte_count): + async def _mock_read(byte_count: int): nonlocal encrypted if byte_count == transport_class.BLOCK_SIZE: return struct.pack(">I", len(encrypted)) @@ -371,7 +620,7 @@ async def _mock_read(byte_count): return encrypted raise ValueError(f"No mock for {byte_count}") - def aio_mock_writer(_, port): + def aio_mock_writer(_: object, port: int): reader = mocker.patch("asyncio.StreamReader") writer = mocker.patch("asyncio.StreamWriter") if custom_port is None: @@ -397,7 +646,7 @@ def aio_mock_writer(_, port): "decrypt_class", [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) -def test_encrypt(encrypt_class, decrypt_class): +def test_encrypt(encrypt_class, decrypt_class) -> None: d = json.dumps({"foo": 1, "bar": 2}) encrypted = encrypt_class.encrypt(d) # encrypt adds a 4 byte header @@ -409,7 +658,7 @@ def test_encrypt(encrypt_class, decrypt_class): "encrypt_class", [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) -def test_encrypt_unicode(encrypt_class): +def test_encrypt_unicode(encrypt_class) -> None: d = "{'snowman': '\u2603'}" e = bytes( @@ -446,7 +695,7 @@ def test_encrypt_unicode(encrypt_class): "decrypt_class", [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], ) -def test_decrypt_unicode(decrypt_class): +def test_decrypt_unicode(decrypt_class) -> None: e = bytes( [ 208, @@ -475,7 +724,7 @@ def test_decrypt_unicode(decrypt_class): assert d == decrypt_class.decrypt(e) -def _get_subclasses(of_class): +def _get_subclasses(of_class: type): package = sys.modules["kasa"] subclasses = set() for _, modname, _ in pkgutil.iter_modules(package.__path__): @@ -494,11 +743,13 @@ def _get_subclasses(of_class): @pytest.mark.parametrize( "class_name_obj", _get_subclasses(BaseProtocol), ids=lambda t: t[0] ) -def test_protocol_init_signature(class_name_obj): +def test_protocol_init_signature(class_name_obj: tuple[str, type]) -> None: if class_name_obj[0].startswith("_"): pytest.skip("Skipping internal protocols") return - params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) + params = list( + inspect.signature(class_name_obj[1].__init__).parameters.values() # type: ignore[misc] + ) assert len(params) == 2 assert params[0].name == "self" @@ -510,8 +761,10 @@ def test_protocol_init_signature(class_name_obj): @pytest.mark.parametrize( "class_name_obj", _get_subclasses(BaseTransport), ids=lambda t: t[0] ) -def test_transport_init_signature(class_name_obj): - params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) +def test_transport_init_signature(class_name_obj: tuple[str, type]) -> None: + params = list( + inspect.signature(class_name_obj[1].__init__).parameters.values() # type: ignore[misc] + ) assert len(params) == 2 assert params[0].name == "self" @@ -561,8 +814,13 @@ def test_transport_init_signature(class_name_obj): ], ) async def test_transport_credentials_hash( - mocker, transport_class, login_version, expected_hash, credentials, expected_blank -): + mocker: MockerFixture, + transport_class: type[BaseTransport], + login_version: int | None, + expected_hash: str | None, + credentials: Credentials | None, + expected_blank: bool, +) -> None: """Test that the actual hashing doesn't break and empty credential returns an empty hash.""" host = "127.0.0.1" @@ -584,7 +842,9 @@ async def test_transport_credentials_hash( "transport_class", [AesTransport, KlapTransport, KlapTransportV2, XorTransport], ) -async def test_transport_credentials_hash_from_config(mocker, transport_class): +async def test_transport_credentials_hash_from_config( + mocker: MockerFixture, transport_class: type[BaseTransport] +) -> None: """Test that credentials_hash provided via config sets correctly.""" host = "127.0.0.1" @@ -603,10 +863,17 @@ async def test_transport_credentials_hash_from_config(mocker, transport_class): [ (ConnectionRefusedError("dummy exception"), False), (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), False), + (OSError(errno.ENETUNREACH, os.strerror(errno.ENETUNREACH)), False), (OSError(errno.ECONNRESET, os.strerror(errno.ECONNRESET)), True), (Exception("dummy exception"), True), ], - ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), + ids=( + "ConnectionRefusedError", + "OSErrorHostDown", + "OSErrorNetUnreach", + "OSErrorRetry", + "Exception", + ), ) @pytest.mark.parametrize( ("protocol_class", "transport_class"), @@ -617,8 +884,12 @@ async def test_transport_credentials_hash_from_config(mocker, transport_class): ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_will_retry_on_connect( - mocker, protocol_class, transport_class, error, retry_expectation -): + mocker: MockerFixture, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], + error: Exception, + retry_expectation: bool, +) -> None: retry_count = 2 conn = mocker.patch("asyncio.open_connection", side_effect=error) config = DeviceConfig("127.0.0.1") @@ -649,13 +920,17 @@ async def test_protocol_will_retry_on_connect( ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), ) async def test_protocol_will_retry_on_write( - mocker, protocol_class, transport_class, error, retry_expectation -): + mocker: MockerFixture, + protocol_class: type[BaseProtocol], + transport_class: type[XorTransport], + error: Exception, + retry_expectation: bool, +) -> None: retry_count = 2 writer = mocker.patch("asyncio.StreamWriter") write_mock = mocker.patch.object(writer, "write", side_effect=error) - def aio_mock_writer(_, __): + def aio_mock_writer(_: object, __: object): nonlocal writer reader = mocker.patch("asyncio.StreamReader") @@ -674,9 +949,9 @@ def aio_mock_writer(_, __): assert write_mock.call_count == expected_call_count -def test_deprecated_protocol(): +def test_deprecated_protocol() -> None: with pytest.deprecated_call(): - from kasa import TPLinkSmartHomeProtocol + from kasa import TPLinkSmartHomeProtocol # type: ignore[attr-defined] with pytest.raises(KasaException, match="host or transport must be supplied"): proto = TPLinkSmartHomeProtocol() @@ -687,7 +962,9 @@ def test_deprecated_protocol(): @device_iot @pytest.mark.xdist_group(name="caplog") -async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): +async def test_iot_queries_redaction( + dev: IotDevice, caplog: pytest.LogCaptureFixture +) -> None: """Test query sensitive info redaction.""" if isinstance(dev.protocol._transport, FakeIotTransport): device_id = "123456789ABCDEF" @@ -721,7 +998,7 @@ async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFi assert "REDACTED_" + device_id[9::] in caplog.text -async def test_redact_data(): +async def test_redact_data() -> None: """Test redact data function.""" data = { "device_id": "123456789ABCDEF", diff --git a/tests/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py similarity index 71% rename from tests/test_smartprotocol.py rename to tests/protocols/test_smartprotocol.py index fce6cd070..9ccbc3cb2 100644 --- a/tests/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -2,6 +2,7 @@ import pytest import pytest_mock +from pytest_mock import MockerFixture from kasa.exceptions import ( SMART_RETRYABLE_ERRORS, @@ -9,11 +10,13 @@ KasaException, SmartErrorCode, ) +from kasa.protocols.smartcamprotocol import SmartCamProtocol from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.smart import SmartDevice -from .conftest import device_smart -from .fakeprotocol_smart import FakeSmartTransport +from ..conftest import device_smart +from ..fakeprotocol_smart import FakeSmartTransport +from ..fakeprotocol_smartcam import FakeSmartCamTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_MULTIPLE_QUERY = { @@ -23,7 +26,9 @@ ERRORS = [e for e in SmartErrorCode if e != 0] -async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): +async def test_smart_queries( + dummy_protocol: SmartProtocol, mocker: pytest_mock.MockerFixture +) -> None: mock_response = {"result": {"great": "success"}, "error_code": 0} mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) @@ -39,7 +44,9 @@ async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) -async def test_smart_device_errors(dummy_protocol, mocker, error_code): +async def test_smart_device_errors( + dummy_protocol: SmartProtocol, mocker: MockerFixture, error_code: SmartErrorCode +) -> None: mock_response = {"result": {"great": "success"}, "error_code": error_code.value} send_mock = mocker.patch.object( @@ -56,8 +63,11 @@ async def test_smart_device_errors(dummy_protocol, mocker, error_code): @pytest.mark.parametrize("error_code", [-13333, 13333]) @pytest.mark.xdist_group(name="caplog") async def test_smart_device_unknown_errors( - dummy_protocol, mocker, error_code, caplog: pytest.LogCaptureFixture -): + dummy_protocol: SmartProtocol, + mocker: MockerFixture, + error_code: int, + caplog: pytest.LogCaptureFixture, +) -> None: """Test handling of unknown error codes.""" mock_response = {"result": {"great": "success"}, "error_code": error_code} @@ -75,8 +85,8 @@ async def test_smart_device_unknown_errors( @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors_in_multiple_request( - dummy_protocol, mocker, error_code -): + dummy_protocol: SmartProtocol, mocker: MockerFixture, error_code: SmartErrorCode +) -> None: mock_request = { "foobar1": {"foo": "bar", "bar": "foo"}, "foobar2": {"foo": "bar", "bar": "foo"}, @@ -110,10 +120,13 @@ async def test_smart_device_errors_in_multiple_request( @pytest.mark.parametrize("request_size", [1, 3, 5, 10]) @pytest.mark.parametrize("batch_size", [1, 2, 3, 4, 5]) async def test_smart_device_multiple_request( - dummy_protocol, mocker, request_size, batch_size -): + dummy_protocol: SmartProtocol, + mocker: MockerFixture, + request_size: int, + batch_size: int, +) -> None: requests = {} - mock_response = { + mock_response: dict = { "result": {"responses": []}, "error_code": 0, } @@ -135,8 +148,8 @@ async def test_smart_device_multiple_request( async def test_smart_device_multiple_request_json_decode_failure( - dummy_protocol, mocker -): + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.""" requests = {} mock_responses = [] @@ -166,8 +179,8 @@ async def test_smart_device_multiple_request_json_decode_failure( async def test_smart_device_multiple_request_json_decode_failure_twice( - dummy_protocol, mocker -): + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.""" requests = {} @@ -193,8 +206,8 @@ async def test_smart_device_multiple_request_json_decode_failure_twice( async def test_smart_device_multiple_request_non_json_decode_failure( - dummy_protocol, mocker -): + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR. Ensure other exception types behave as expected. @@ -222,7 +235,9 @@ async def test_smart_device_multiple_request_non_json_decode_failure( assert send_mock.call_count == 1 -async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker): +async def test_childdevicewrapper_unwrapping( + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test that responseData gets unwrapped correctly.""" wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) mock_response = {"error_code": 0, "result": {"responseData": {"error_code": 0}}} @@ -232,7 +247,9 @@ async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker): assert res == {"foobar": None} -async def test_childdevicewrapper_unwrapping_with_payload(dummy_protocol, mocker): +async def test_childdevicewrapper_unwrapping_with_payload( + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) mock_response = { "error_code": 0, @@ -243,7 +260,9 @@ async def test_childdevicewrapper_unwrapping_with_payload(dummy_protocol, mocker assert res == {"foobar": {"bar": "bar"}} -async def test_childdevicewrapper_error(dummy_protocol, mocker): +async def test_childdevicewrapper_error( + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test that errors inside the responseData payload cause an exception.""" wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) mock_response = {"error_code": 0, "result": {"responseData": {"error_code": -1001}}} @@ -253,7 +272,9 @@ async def test_childdevicewrapper_error(dummy_protocol, mocker): await wrapped_protocol.query(DUMMY_QUERY) -async def test_childdevicewrapper_unwrapping_multiplerequest(dummy_protocol, mocker): +async def test_childdevicewrapper_unwrapping_multiplerequest( + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test that unwrapping multiplerequest works correctly.""" mock_response = { "error_code": 0, @@ -282,7 +303,9 @@ async def test_childdevicewrapper_unwrapping_multiplerequest(dummy_protocol, moc assert resp == {"get_device_info": {"foo": "bar"}, "second_command": {"bar": "foo"}} -async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): +async def test_childdevicewrapper_multiplerequest_error( + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: """Test that errors inside multipleRequest response of responseData raise an exception.""" mock_response = { "error_code": 0, @@ -310,7 +333,9 @@ async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): @pytest.mark.parametrize("list_sum", [5, 10, 30]) @pytest.mark.parametrize("batch_size", [1, 2, 3, 50]) -async def test_smart_protocol_lists_single_request(mocker, list_sum, batch_size): +async def test_smart_protocol_lists_single_request( + mocker: MockerFixture, list_sum: int, batch_size: int +) -> None: child_device_list = [{"foo": i} for i in range(list_sum)] response = { "get_child_device_list": { @@ -338,7 +363,9 @@ async def test_smart_protocol_lists_single_request(mocker, list_sum, batch_size) @pytest.mark.parametrize("list_sum", [5, 10, 30]) @pytest.mark.parametrize("batch_size", [1, 2, 3, 50]) -async def test_smart_protocol_lists_multiple_request(mocker, list_sum, batch_size): +async def test_smart_protocol_lists_multiple_request( + mocker: MockerFixture, list_sum: int, batch_size: int +) -> None: child_list = [{"foo": i} for i in range(list_sum)] response = { "get_child_device_list": { @@ -371,7 +398,51 @@ async def test_smart_protocol_lists_multiple_request(mocker, list_sum, batch_siz assert resp == response -async def test_incomplete_list(mocker, caplog): +@pytest.mark.parametrize("list_sum", [5, 10, 30]) +@pytest.mark.parametrize("batch_size", [1, 2, 3, 50]) +async def test_smartcam_protocol_list_request( + mocker: MockerFixture, list_sum: int, batch_size: int +) -> None: + """Test smartcam protocol list handling for lists.""" + child_list = [{"foo": i} for i in range(list_sum)] + + response = { + "getChildDeviceList": { + "child_device_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + "getChildDeviceComponentList": { + "child_component_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + } + request = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + "getChildDeviceComponentList": {"childControl": {"start_index": 0}}, + } + + ft = FakeSmartCamTransport( + response, + "foobar", + list_return_size=batch_size, + components_not_included=True, + get_child_fixtures=False, + ) + protocol = SmartCamProtocol(transport=ft) + query_spy = mocker.spy(protocol, "_execute_query") + resp = await protocol.query(request) + expected_count = 1 + 2 * ( + int(list_sum / batch_size) + (0 if list_sum % batch_size else -1) + ) + assert query_spy.call_count == expected_count + assert resp == response + + +async def test_incomplete_list( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: """Test for handling incomplete lists returned from queries.""" info = { "get_preset_rules": { @@ -421,7 +492,7 @@ async def test_incomplete_list(mocker, caplog): @pytest.mark.xdist_group(name="caplog") async def test_smart_queries_redaction( dev: SmartDevice, caplog: pytest.LogCaptureFixture -): +) -> None: """Test query sensitive info redaction.""" if isinstance(dev.protocol._transport, FakeSmartTransport): device_id = "123456789ABCDEF" @@ -448,3 +519,81 @@ async def test_smart_queries_redaction( await dev.update() assert device_id not in caplog.text assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_no_method_returned_multiple( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test protocol handles multiple requests that don't return the method.""" + req = { + "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, + "getAppComponentList": {"app_component": {"name": "app_component_list"}}, + } + res = { + "result": { + "responses": [ + { + "method": "getDeviceInfo", + "result": { + "device_info": { + "basic_info": { + "device_model": "C210", + }, + } + }, + "error_code": 0, + }, + { + "result": {"app_component": {"app_component_list": []}}, + "error_code": 0, + }, + ] + }, + "error_code": 0, + } + + transport = FakeSmartCamTransport( + {}, + "dummy-name", + components_not_included=True, + ) + protocol = SmartProtocol(transport=transport) + mocker.patch.object(protocol._transport, "send", return_value=res) + await protocol.query(req) + assert "No method key in response" in caplog.text + caplog.clear() + await protocol.query(req) + assert "No method key in response" not in caplog.text + + +async def test_no_multiple_methods( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test protocol sends NO_MULTI methods as single call.""" + req = { + "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, + "getConnectStatus": {"onboarding": {"get_connect_status": {}}}, + } + info = { + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Home", + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": {"current_ssid": "", "err_code": 0, "status": 0} + } + }, + } + transport = FakeSmartCamTransport( + info, + "dummy-name", + components_not_included=True, + ) + protocol = SmartProtocol(transport=transport) + send_spy = mocker.spy(protocol._transport, "send") + await protocol.query(req) + assert send_spy.call_count == 2 diff --git a/tests/smart/features/test_brightness.py b/tests/smart/features/test_brightness.py index ff38854a8..d0db11aa5 100644 --- a/tests/smart/features/test_brightness.py +++ b/tests/smart/features/test_brightness.py @@ -9,7 +9,7 @@ @brightness -async def test_brightness_component(dev: SmartDevice): +async def test_brightness_component(dev: SmartDevice) -> None: """Test brightness feature.""" brightness = next(get_parent_and_child_modules(dev, "Brightness")) assert brightness @@ -35,7 +35,7 @@ async def test_brightness_component(dev: SmartDevice): @dimmable_iot -async def test_brightness_dimmable(dev: IotDevice): +async def test_brightness_dimmable(dev: IotDevice) -> None: """Test brightness feature.""" assert isinstance(dev, IotDevice) assert "brightness" in dev.sys_info or bool(dev.sys_info["is_dimmable"]) diff --git a/tests/smart/features/test_colortemp.py b/tests/smart/features/test_colortemp.py index 055c5b299..4831c1ce5 100644 --- a/tests/smart/features/test_colortemp.py +++ b/tests/smart/features/test_colortemp.py @@ -6,7 +6,7 @@ @variable_temp_smart -async def test_colortemp_component(dev: SmartDevice): +async def test_colortemp_component(dev: SmartDevice) -> None: """Test brightness feature.""" assert isinstance(dev, SmartDevice) assert "color_temperature" in dev._components diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py new file mode 100644 index 000000000..3af80e995 --- /dev/null +++ b/tests/smart/modules/test_alarm.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from typing import TypedDict + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules import Alarm +from kasa.smart.modules.alarm import AlarmVolume + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +alarm = parametrize("has alarm", component_filter="alarm", protocol_filter={"SMART"}) + + +class PlayAlarmKwargs(TypedDict, total=False): + duration: int + volume: AlarmVolume | int + sound: str + + +class PlayAlarmRequestParams(TypedDict, total=False): + alarm_duration: int + alarm_volume: AlarmVolume + alarm_type: str + + +@alarm +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("alarm", "active", bool), + ("alarm_source", "source", str | None), + ("alarm_sound", "alarm_sound", str), + ("alarm_volume", "_alarm_volume_str", str), + ("alarm_volume_level", "alarm_volume", int), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + assert alarm is not None + + prop = getattr(alarm, prop_name) + assert isinstance(prop, type) + + feat = alarm._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@alarm +async def test_volume_feature(dev: SmartDevice) -> None: + """Test that volume features have correct choices and range.""" + alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + assert alarm is not None + + volume_str_feat = alarm.get_feature("_alarm_volume_str") + assert volume_str_feat + + assert volume_str_feat.choices == ["mute", "low", "normal", "high"] + + volume_int_feat = alarm.get_feature("alarm_volume") + assert volume_int_feat.minimum_value == 0 + assert volume_int_feat.maximum_value == 3 + + +@alarm +@pytest.mark.parametrize( + ("kwargs", "request_params"), + [ + pytest.param({"volume": "low"}, {"alarm_volume": "low"}, id="volume"), + pytest.param({"volume": 0}, {"alarm_volume": "mute"}, id="volume-integer"), + pytest.param({"duration": 1}, {"alarm_duration": 1}, id="duration"), + pytest.param( + {"sound": "Doorbell Ring 1"}, {"alarm_type": "Doorbell Ring 1"}, id="sound" + ), + ], +) +async def test_play( + dev: SmartDevice, + kwargs: PlayAlarmKwargs, + request_params: PlayAlarmRequestParams, + mocker: MockerFixture, +) -> None: + """Test that play parameters are handled correctly.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await alarm.play(**kwargs) + + call_spy.assert_called_with("play_alarm", request_params) + + with pytest.raises(ValueError, match="Invalid duration"): + await alarm.play(duration=-1) + + with pytest.raises(ValueError, match="Invalid sound"): + await alarm.play(sound="unknown") + + with pytest.raises(ValueError, match="Invalid volume"): + await alarm.play(volume="unknown") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Invalid volume"): + await alarm.play(volume=-1) + + +@alarm +async def test_stop(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test that stop creates the correct call.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await alarm.stop() + + call_spy.assert_called_with("stop_alarm") + + +@alarm +@pytest.mark.parametrize( + ("method", "value", "target_key"), + [ + pytest.param( + "set_alarm_sound", "Doorbell Ring 1", "type", id="set_alarm_sound" + ), + pytest.param("set_alarm_volume", "low", "volume", id="set_alarm_volume"), + pytest.param("set_alarm_duration", 10, "duration", id="set_alarm_duration"), + ], +) +async def test_set_alarm_configure( + dev: SmartDevice, + mocker: MockerFixture, + method: str, + value: str | int, + target_key: str, +) -> None: + """Test that set_alarm_sound creates the correct call.""" + alarm: Alarm = next(get_parent_and_child_modules(dev, Module.Alarm)) + call_spy = mocker.spy(alarm, "call") + await getattr(alarm, method)(value) + + expected_params = {"duration": mocker.ANY, "type": mocker.ANY, "volume": mocker.ANY} + expected_params[target_key] = value + + call_spy.assert_called_with("set_alarm_configure", expected_params) diff --git a/tests/smart/modules/test_autooff.py b/tests/smart/modules/test_autooff.py index b8042aa60..cb804830c 100644 --- a/tests/smart/modules/test_autooff.py +++ b/tests/smart/modules/test_autooff.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from datetime import datetime import pytest @@ -25,13 +24,9 @@ ("auto_off_at", "auto_off_at", datetime | None), ], ) -@pytest.mark.skipif( - sys.version_info < (3, 10), - reason="Subscripted generics cannot be used with class and instance checks", -) async def test_autooff_features( dev: SmartDevice, feature: str, prop_name: str, type: type -): +) -> None: """Test that features are registered and work as expected.""" autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) assert autooff is not None @@ -45,7 +40,7 @@ async def test_autooff_features( @autooff -async def test_settings(dev: SmartDevice, mocker: MockerFixture): +async def test_settings(dev: SmartDevice, mocker: MockerFixture) -> None: """Test autooff settings.""" autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) assert autooff @@ -84,7 +79,7 @@ async def test_settings(dev: SmartDevice, mocker: MockerFixture): @pytest.mark.parametrize("is_timer_active", [True, False]) async def test_auto_off_at( dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool -): +) -> None: """Test auto-off at sensor.""" autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) assert autooff diff --git a/tests/smart/modules/test_childlock.py b/tests/smart/modules/test_childlock.py new file mode 100644 index 000000000..a312d5f4c --- /dev/null +++ b/tests/smart/modules/test_childlock.py @@ -0,0 +1,46 @@ +import pytest + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import parametrize + +childlock = parametrize( + "has child lock", + component_filter="button_and_led", + protocol_filter={"SMART"}, +) + + +@childlock +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("child_lock", "enabled", bool), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + protect = dev.modules[Module.ChildLock] + assert protect is not None + + prop = getattr(protect, prop_name) + assert isinstance(prop, type) + + feat = protect._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@childlock +async def test_enabled(dev: SmartDevice) -> None: + """Test the API.""" + protect = dev.modules[Module.ChildLock] + assert protect is not None + + assert isinstance(protect.enabled, bool) + await protect.set_enabled(False) + await dev.update() + assert protect.enabled is False diff --git a/tests/smart/modules/test_childprotection.py b/tests/smart/modules/test_childprotection.py index ad2878e57..e00cbb452 100644 --- a/tests/smart/modules/test_childprotection.py +++ b/tests/smart/modules/test_childprotection.py @@ -1,7 +1,7 @@ import pytest from kasa import Module -from kasa.smart.modules import ChildProtection +from kasa.smart import SmartDevice from ...device_fixtures import parametrize @@ -19,9 +19,11 @@ ("child_lock", "enabled", bool), ], ) -async def test_features(dev, feature, prop_name, type): +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: """Test that features are registered and work as expected.""" - protect: ChildProtection = dev.modules[Module.ChildProtection] + protect = dev.modules[Module.ChildProtection] assert protect is not None prop = getattr(protect, prop_name) @@ -33,9 +35,9 @@ async def test_features(dev, feature, prop_name, type): @child_protection -async def test_enabled(dev): +async def test_enabled(dev: SmartDevice) -> None: """Test the API.""" - protect: ChildProtection = dev.modules[Module.ChildProtection] + protect = dev.modules[Module.ChildProtection] assert protect is not None assert isinstance(protect.enabled, bool) diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py new file mode 100644 index 000000000..749fcc573 --- /dev/null +++ b/tests/smart/modules/test_childsetup.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, Module + +from ...device_fixtures import parametrize + +childsetup = parametrize( + "supports pairing", component_filter="child_quick_setup", protocol_filter={"SMART"} +) + + +@childsetup +async def test_childsetup_features(dev: Device) -> None: + """Test the exposed features.""" + cs = dev.modules.get(Module.ChildSetup) + assert cs + + assert "pair" in cs._module_features + pair = cs._module_features["pair"] + assert pair.type == Feature.Type.Action + + +@childsetup +async def test_childsetup_pair( + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test device pairing.""" + caplog.set_level(logging.INFO) + mock_query_helper = mocker.spy(dev, "_query_helper") + mocker.patch("asyncio.sleep") + + cs = dev.modules.get(Module.ChildSetup) + assert cs + + await cs.pair() + + mock_query_helper.assert_has_awaits( + [ + mocker.call("begin_scanning_child_device", None), + mocker.call("get_scan_child_device_list", params=mocker.ANY), + mocker.call("add_child_device_list", params=mocker.ANY), + ] + ) + assert "Discovery done" in caplog.text + + +@childsetup +async def test_childsetup_unpair( + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test unpair.""" + mock_query_helper = mocker.spy(dev, "_query_helper") + DUMMY_ID = "dummy_id" + + cs = dev.modules.get(Module.ChildSetup) + assert cs + + await cs.unpair(DUMMY_ID) + + mock_query_helper.assert_awaited_with( + "remove_child_device_list", + params={"child_device_list": [{"device_id": DUMMY_ID}]}, + ) diff --git a/tests/smart/modules/test_clean.py b/tests/smart/modules/test_clean.py new file mode 100644 index 000000000..4841b8628 --- /dev/null +++ b/tests/smart/modules/test_clean.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.clean import CleanMode, ErrorCode, FanSpeed, RoomInfo, Status + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +clean = parametrize("clean module", component_filter="clean", protocol_filter={"SMART"}) + + +@clean +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("vacuum_status", "status", Status), + ("vacuum_error", "error", ErrorCode), + ("vacuum_fan_speed", "fan_speed_preset", str), + ("carpet_boost", "carpet_boost", bool), + ("battery_level", "battery", int), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + assert clean is not None + + prop = getattr(clean, prop_name) + assert isinstance(prop, type) + + feat = clean._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@pytest.mark.parametrize( + ("feature", "value", "method", "params"), + [ + pytest.param( + "vacuum_start", + 1, + "setSwitchClean", + { + "clean_mode": 0, + "clean_on": True, + "clean_order": True, + "force_clean": False, + }, + id="vacuum_start", + ), + pytest.param( + "vacuum_pause", 1, "setRobotPause", {"pause": True}, id="vacuum_pause" + ), + pytest.param( + "vacuum_return_home", + 1, + "setSwitchCharge", + {"switch_charge": True}, + id="vacuum_return_home", + ), + pytest.param( + "vacuum_fan_speed", + "Quiet", + "setCleanAttr", + {"suction": 1, "type": "global"}, + id="vacuum_fan_speed", + ), + pytest.param( + "carpet_boost", + True, + "setCarpetClean", + {"carpet_clean_prefer": "boost"}, + id="carpet_boost", + ), + pytest.param( + "clean_count", + 2, + "setCleanAttr", + {"clean_number": 2, "type": "global"}, + id="clean_count", + ), + ], +) +@clean +async def test_actions( + dev: SmartDevice, + mocker: MockerFixture, + feature: str, + value: str | int, + method: str, + params: dict, +) -> None: + """Test the clean actions.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + call = mocker.spy(clean, "call") + + await dev.features[feature].set_value(value) + call.assert_called_with(method, params) + + +@pytest.mark.parametrize( + ("err_status", "error", "warning_msg"), + [ + pytest.param([], ErrorCode.Ok, None, id="empty error"), + pytest.param([0], ErrorCode.Ok, None, id="no error"), + pytest.param([3], ErrorCode.MainBrushStuck, None, id="known error"), + pytest.param( + [123], + ErrorCode.UnknownInternal, + "Unknown error code, please create an issue describing the error: 123", + id="unknown error", + ), + pytest.param( + [3, 4], + ErrorCode.MainBrushStuck, + "Multiple error codes, using the first one only: [3, 4]", + id="multi-error", + ), + ], +) +@clean +async def test_post_update_hook( + dev: SmartDevice, + err_status: list, + error: ErrorCode, + warning_msg: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that post update hook sets error states correctly.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + assert clean + + caplog.set_level(logging.DEBUG) + + # _post_update_hook will pop an item off the status list so create a copy. + err_status = [e for e in err_status] + clean.data["getVacStatus"]["err_status"] = err_status + + await clean._post_update_hook() + + assert clean._error_code is error + + if error is not ErrorCode.Ok: + assert clean.status is Status.Error + + if warning_msg: + assert warning_msg in caplog.text + + # Check doesn't log twice + caplog.clear() + await clean._post_update_hook() + + if warning_msg: + assert warning_msg not in caplog.text + + +@clean +async def test_resume(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test that start calls resume if the state is paused.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + call = mocker.spy(clean, "call") + resume = mocker.spy(clean, "resume") + + mocker.patch.object( + type(clean), + "status", + new_callable=mocker.PropertyMock, + return_value=Status.Paused, + ) + await clean.start() + + call.assert_called_with("setRobotPause", {"pause": False}) + resume.assert_awaited() + + +@clean +async def test_unknown_status( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test that unknown status is logged.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + caplog.set_level(logging.DEBUG) + clean.data["getVacStatus"]["status"] = 123 + + assert clean.status is Status.UnknownInternal + assert "Got unknown status code: 123" in caplog.text + + # Check only logs once + caplog.clear() + + assert clean.status is Status.UnknownInternal + assert "Got unknown status code: 123" not in caplog.text + + # Check logs again for other errors + + caplog.clear() + clean.data["getVacStatus"]["status"] = 123456 + + assert clean.status is Status.UnknownInternal + assert "Got unknown status code: 123456" in caplog.text + + +@clean +@pytest.mark.parametrize( + ("setting", "value", "exc", "exc_message"), + [ + pytest.param( + "vacuum_fan_speed", + "invalid speed", + ValueError, + "Invalid fan speed", + id="vacuum_fan_speed", + ), + ], +) +async def test_invalid_settings( + dev: SmartDevice, + mocker: MockerFixture, + setting: str, + value: str, + exc: type[Exception], + exc_message: str, +) -> None: + """Test invalid settings.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + # Not using feature.set_value() as it checks for valid values + setter_name = dev.features[setting].attribute_setter + assert isinstance(setter_name, str) + + setter = getattr(clean, setter_name) + + with pytest.raises(exc, match=exc_message): + await setter(value) + + +@clean +async def test_current_map_name(dev: SmartDevice, mocker: MockerFixture): + """Test current_map_name decodes the base64 name of the active map.""" + import base64 + + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + map_info = { + "current_map_id": 42, + "map_list": [ + {"map_id": 42, "map_name": base64.b64encode(b"Upstairs").decode()}, + {"map_id": 99, "map_name": base64.b64encode(b"Downstairs").decode()}, + ], + } + mocker.patch.object( + type(clean), + "data", + new_callable=mocker.PropertyMock, + return_value={**clean.data, "getMapInfo": map_info}, + ) + assert clean.current_map_name == "Upstairs" + + +@clean +async def test_current_map_name_missing(dev: SmartDevice, mocker: MockerFixture): + """Test current_map_name returns None when map_list is empty.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + map_info = {"current_map_id": 0, "map_list": []} + mocker.patch.object( + type(clean), + "data", + new_callable=mocker.PropertyMock, + return_value={**clean.data, "getMapInfo": map_info}, + ) + assert clean.current_map_name is None + + +@clean +async def test_clean_rooms(dev: SmartDevice, mocker: MockerFixture): + """Test clean_rooms sends the correct setSwitchClean payload.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + call = mocker.spy(clean, "call") + + room_ids = [2, 3] + await clean.clean_rooms(room_ids) + + call.assert_called_with( + "setSwitchClean", + { + "clean_mode": CleanMode.Room, + "clean_on": True, + "clean_order": True, + "force_clean": False, + "map_id": clean.current_map_id, + "room_list": room_ids, + "start_type": 1, + }, + ) + + +@clean +async def test_clean_rooms_explicit_map_id(dev: SmartDevice, mocker: MockerFixture): + """Test clean_rooms uses the provided map_id when given.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + call = mocker.spy(clean, "call") + + await clean.clean_rooms([5], map_id=12345) + + call.assert_called_with( + "setSwitchClean", + { + "clean_mode": CleanMode.Room, + "clean_on": True, + "clean_order": True, + "force_clean": False, + "map_id": 12345, + "room_list": [5], + "start_type": 1, + }, + ) + + +@clean +async def test_clean_rooms_empty_raises(dev: SmartDevice): + """Test clean_rooms raises ValueError when room_ids is empty.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + with pytest.raises(ValueError, match="room_ids must not be empty"): + await clean.clean_rooms([]) + + +@clean +async def test_get_rooms(dev: SmartDevice, mocker: MockerFixture): + """Test get_rooms returns RoomInfo objects with decoded names.""" + import base64 + + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + map_data = { + "area_list": [ + { + "id": 2, + "name": base64.b64encode(b"Kitchen").decode(), + "type": "room", + "color": 1, + "suction": 2, + "cistern": 1, + "clean_number": 1, + }, + { + "id": 3, + "name": base64.b64encode(b"Living Room").decode(), + "type": "room", + "color": 2, + "suction": 3, + "cistern": 2, + "clean_number": 2, + }, + {"id": 401, "type": "virtual_wall", "vertexs": []}, + ] + } + call_mock = mocker.patch.object(clean, "call", return_value=map_data) + + rooms = await clean.get_rooms() + + call_mock.assert_called_once_with( + "getMapData", {"map_id": clean.current_map_id, "type": 0} + ) + assert len(rooms) == 2 + assert all(isinstance(r, RoomInfo) for r in rooms) + assert rooms[0].id == 2 + assert rooms[0].name == "Kitchen" + assert rooms[0].color == 1 + assert rooms[0].suction is FanSpeed.Standard + assert rooms[1].id == 3 + assert rooms[1].name == "Living Room" + assert rooms[1].suction is FanSpeed.Turbo + + +@clean +async def test_get_rooms_explicit_map_id(dev: SmartDevice, mocker: MockerFixture): + """Test get_rooms uses the provided map_id when given.""" + import base64 + + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + map_data = { + "area_list": [ + { + "id": 1, + "name": base64.b64encode(b"Hall").decode(), + "type": "room", + }, + ] + } + call_mock = mocker.patch.object(clean, "call", return_value=map_data) + + rooms = await clean.get_rooms(map_id=99999) + + call_mock.assert_called_once_with("getMapData", {"map_id": 99999, "type": 0}) + assert len(rooms) == 1 + assert rooms[0].name == "Hall" + + +@clean +async def test_get_rooms_no_name(dev: SmartDevice, mocker: MockerFixture): + """Test get_rooms handles rooms without names.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + map_data = {"area_list": [{"id": 5, "type": "room"}]} + mocker.patch.object(clean, "call", return_value=map_data) + + rooms = await clean.get_rooms() + + assert len(rooms) == 1 + assert rooms[0].id == 5 + assert rooms[0].name is None + assert rooms[0].suction is None + + +@clean +async def test_clean_type(dev: SmartDevice, mocker: MockerFixture): + """Test clean_type returns the correct CleanMode.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + mocker.patch.object( + type(clean), + "data", + new_callable=mocker.PropertyMock, + return_value={**clean.data, "getCleanStatus": {"clean_status": 0}}, + ) + assert clean.clean_type is CleanMode.StandardHome + + mocker.patch.object( + type(clean), + "data", + new_callable=mocker.PropertyMock, + return_value={**clean.data, "getCleanStatus": {"clean_status": 3}}, + ) + assert clean.clean_type is CleanMode.Room + + +@clean +async def test_clean_type_missing(dev: SmartDevice, mocker: MockerFixture): + """Test clean_type returns None when clean_status is unavailable.""" + clean = next(get_parent_and_child_modules(dev, Module.Clean)) + + data_without = {k: v for k, v in clean.data.items() if k != "getCleanStatus"} + mocker.patch.object( + type(clean), + "data", + new_callable=mocker.PropertyMock, + return_value=data_without, + ) + assert clean.clean_type is None diff --git a/tests/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py new file mode 100644 index 000000000..adfa1cefe --- /dev/null +++ b/tests/smart/modules/test_cleanrecords.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +cleanrecords = parametrize( + "has clean records", component_filter="clean_percent", protocol_filter={"SMART"} +) + + +@cleanrecords +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("total_clean_area", "total_clean_area", int), + ("total_clean_time", "total_clean_time", timedelta), + ("last_clean_area", "last_clean_area", int), + ("last_clean_time", "last_clean_time", timedelta), + ("total_clean_count", "total_clean_count", int), + ("last_clean_timestamp", "last_clean_timestamp", datetime), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + records = next(get_parent_and_child_modules(dev, Module.CleanRecords)) + assert records is not None + + prop = getattr(records, prop_name) + assert isinstance(prop, type) + + feat = records._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@cleanrecords +async def test_timezone(dev: SmartDevice) -> None: + """Test that timezone is added to timestamps.""" + clean_records = next(get_parent_and_child_modules(dev, Module.CleanRecords)) + assert clean_records is not None + + assert isinstance(clean_records.last_clean_timestamp, datetime) + assert clean_records.last_clean_timestamp.tzinfo + + # Check for zone info to ensure that this wasn't picking upthe default + # of utc before the time module is updated. + assert isinstance(clean_records.last_clean_timestamp.tzinfo, ZoneInfo) + + for record in clean_records.parsed_data.records: + assert isinstance(record.timestamp, datetime) + assert record.timestamp.tzinfo + assert isinstance(record.timestamp.tzinfo, ZoneInfo) diff --git a/tests/smart/modules/test_consumables.py b/tests/smart/modules/test_consumables.py new file mode 100644 index 000000000..4b61d3e09 --- /dev/null +++ b/tests/smart/modules/test_consumables.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.consumables import CONSUMABLE_METAS + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +consumables = parametrize( + "has consumables", component_filter="consumables", protocol_filter={"SMART"} +) + + +@consumables +@pytest.mark.parametrize( + "consumable_name", [consumable.id for consumable in CONSUMABLE_METAS] +) +@pytest.mark.parametrize("postfix", ["used", "remaining"]) +async def test_features(dev: SmartDevice, consumable_name: str, postfix: str) -> None: + """Test that features are registered and work as expected.""" + consumables = next(get_parent_and_child_modules(dev, Module.Consumables)) + assert consumables is not None + + feature_name = f"{consumable_name}_{postfix}" + + feat = consumables._device.features[feature_name] + assert isinstance(feat.value, timedelta) + + +@consumables +@pytest.mark.parametrize( + ("consumable_name", "data_key"), + [(consumable.id, consumable.data_key) for consumable in CONSUMABLE_METAS], +) +async def test_erase( + dev: SmartDevice, mocker: MockerFixture, consumable_name: str, data_key: str +) -> None: + """Test autocollection switch.""" + consumables = next(get_parent_and_child_modules(dev, Module.Consumables)) + call = mocker.spy(consumables, "call") + + feature_name = f"{consumable_name}_reset" + feat = dev._features[feature_name] + await feat.set_value(True) + + call.assert_called_with( + "resetConsumablesTime", {"reset_list": [data_key.removesuffix("_time")]} + ) diff --git a/tests/smart/modules/test_contact.py b/tests/smart/modules/test_contact.py index c5c4c935f..384dae846 100644 --- a/tests/smart/modules/test_contact.py +++ b/tests/smart/modules/test_contact.py @@ -16,7 +16,7 @@ ("is_open", bool), ], ) -async def test_contact_features(dev: Device, feature, type): +async def test_contact_features(dev: Device, feature: str, type: type) -> None: """Test that features are registered and work as expected.""" contact = dev.modules.get(Module.ContactSensor) assert contact is not None diff --git a/tests/smart/modules/test_dustbin.py b/tests/smart/modules/test_dustbin.py new file mode 100644 index 000000000..5f685ced9 --- /dev/null +++ b/tests/smart/modules/test_dustbin.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.dustbin import Mode + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +dustbin = parametrize( + "has dustbin", component_filter="dust_bucket", protocol_filter={"SMART"} +) + + +@dustbin +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("dustbin_autocollection_enabled", "auto_collection", bool), + ("dustbin_mode", "mode", str), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + assert dustbin is not None + + prop = getattr(dustbin, prop_name) + assert isinstance(prop, type) + + feat = dustbin._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@dustbin +async def test_dustbin_mode(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test dust mode.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + mode_feature = dustbin._device.features["dustbin_mode"] + assert dustbin.mode == mode_feature.value + + new_mode = Mode.Max + await dustbin.set_mode(new_mode.name) + + params = dustbin._settings.copy() + params["dust_collection_mode"] = new_mode.value + + call.assert_called_with("setDustCollectionInfo", params) + + await dev.update() + + assert dustbin.mode == new_mode.name + + with pytest.raises(ValueError, match="Invalid auto/emptying mode speed"): + await dustbin.set_mode("invalid") + + +@dustbin +async def test_dustbin_mode_off(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test dustbin_mode == Off.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + auto_collection = dustbin._device.features["dustbin_mode"] + await auto_collection.set_value(Mode.Off.name) + + params = dustbin._settings.copy() + params["auto_dust_collection"] = False + + call.assert_called_with("setDustCollectionInfo", params) + + await dev.update() + assert dustbin.auto_collection is False + assert dustbin.mode is Mode.Off.name + + +@dustbin +async def test_autocollection(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test autocollection switch.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + auto_collection = dustbin._device.features["dustbin_autocollection_enabled"] + assert dustbin.auto_collection == auto_collection.value + + await auto_collection.set_value(True) + + params = dustbin._settings.copy() + params["auto_dust_collection"] = True + + call.assert_called_with("setDustCollectionInfo", params) + + await dev.update() + + assert dustbin.auto_collection is True + + +@dustbin +async def test_empty_dustbin(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test the empty dustbin feature.""" + dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin)) + call = mocker.spy(dustbin, "call") + + await dustbin.start_emptying() + + call.assert_called_with("setSwitchDustCollection", {"switch_dust_collection": True}) diff --git a/tests/smart/modules/test_energy.py b/tests/smart/modules/test_energy.py new file mode 100644 index 000000000..18a6ccc4f --- /dev/null +++ b/tests/smart/modules/test_energy.py @@ -0,0 +1,426 @@ +import copy +import logging +from contextlib import nullcontext as does_not_raise +from unittest.mock import patch + +import pytest + +from kasa import DeviceError, Module +from kasa.exceptions import SmartErrorCode +from kasa.interfaces.energy import Energy +from kasa.smart import SmartDevice +from kasa.smart.modules import Energy as SmartEnergyModule + +from ...device_fixtures import has_emeter_smart, parametrize + +s515d_smart = parametrize( + "s515d smart", + model_filter={"S515D(US)_1.6_1.0.4"}, + protocol_filter={"SMART"}, +) +p110_v1_smart = parametrize( + "p110 v1 smart", + model_filter={"P110(EU)_1.0_1.0.7"}, + protocol_filter={"SMART"}, +) + + +def _get_energy_module(dev: SmartDevice) -> SmartEnergyModule: + energy_module = dev.modules.get(Module.Energy) + if not energy_module: + pytest.skip(f"Energy module not supported for {dev}.") + + assert isinstance(energy_module, SmartEnergyModule) + return energy_module + + +def _get_v2_energy_module(dev: SmartDevice) -> SmartEnergyModule: + energy_module = _get_energy_module(dev) + if energy_module.supported_version <= 1: + pytest.skip("Only applicable for energy v2+ fixtures.") + + return energy_module + + +def _energy_usage(current_power: int | None = None) -> dict[str, int]: + energy_usage = { + "month_energy": 0, + "today_energy": 0, + } + if current_power is not None: + energy_usage["current_power"] = current_power + + return energy_usage + + +def _copy_last_update(dev: SmartDevice, *remove_keys: str) -> dict[str, object]: + """Copy the cached update response, removing keys from the cache if needed.""" + response = copy.deepcopy(dev._last_update) + for key in remove_keys: + response.pop(key, None) + dev._last_update.pop(key, None) + + return response + + +def _device_error(method: str, error_code: SmartErrorCode) -> DeviceError: + return DeviceError(method, error_code=error_code) + + +def _mock_query(responses: dict[str, object], calls: list[str]): + async def query(request: dict[str, dict | None]) -> dict[str, object]: + method = next(iter(request)) + calls.append(method) + + if method not in responses: + raise AssertionError(f"Unexpected request: {request}") + + response = responses[method] + if isinstance(response, DeviceError): + raise response + + return {method: response} + + return query + + +@has_emeter_smart +async def test_supported(dev: SmartDevice) -> None: + energy_module = _get_energy_module(dev) + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False + if energy_module.supported_version < 2: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False + else: + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True + + +@has_emeter_smart +async def test_get_energy_usage_error( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +) -> None: + """Test errors on get_energy_usage.""" + caplog.set_level(logging.DEBUG) + + energy_module = _get_energy_module(dev) + version = energy_module.supported_version + + expected_raise = does_not_raise() if version > 1 else pytest.raises(DeviceError) + if version > 1: + expected = "get_energy_usage" + expected_current_consumption = 2.002 + else: + expected = "current_power" + expected_current_consumption = None + + assert expected in energy_module.data + assert energy_module.current_consumption is not None + assert energy_module.consumption_today is not None + assert energy_module.consumption_this_month is not None + + resp = _copy_last_update(dev) + + if version > 1: + resp["get_emeter_data"] = {"power_mw": 2002} + resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR + + # version 1 only has get_energy_usage so module should raise an error if + # version 1 and get_energy_usage is in error + with patch.object(dev.protocol, "query", return_value=resp): + await dev.update() + + with expected_raise: + assert "get_energy_usage" not in energy_module.data + + assert energy_module.current_consumption == expected_current_consumption + assert energy_module.consumption_today is None + assert energy_module.consumption_this_month is None + + msg = ( + f"Removed key get_energy_usage from response for device {dev.host}" + " as it returned error: JSON_DECODE_FAIL_ERROR" + ) + if version > 1: + assert msg in caplog.text + + +@has_emeter_smart +@pytest.mark.parametrize( + ("remove_keys", "response_updates", "expected_power"), + [ + pytest.param( + (), + { + "get_emeter_data": {"power_mw": 3003}, + "get_energy_usage": _energy_usage(current_power=2002), + "get_current_power": {"current_power": 1.001}, + }, + 3.003, + id="prefers_get_emeter_data", + ), + pytest.param( + ("get_emeter_data",), + { + "get_energy_usage": _energy_usage(current_power=2002), + "get_current_power": {"current_power": 1.001}, + }, + 2.002, + id="falls_back_to_get_energy_usage", + ), + ], +) +async def test_v2_current_power_source_precedence( + dev: SmartDevice, + remove_keys: tuple[str, ...], + response_updates: dict[str, object], + expected_power: float, +) -> None: + """Prefer higher precision current power sources for v2 devices.""" + energy_module = _get_v2_energy_module(dev) + resp = _copy_last_update(dev, *remove_keys) + resp.update(response_updates) + + with patch.object(dev.protocol, "query", return_value=resp): + await dev.update() + + assert energy_module.current_consumption == expected_power + assert energy_module.status.power == expected_power + + +@has_emeter_smart +@pytest.mark.parametrize( + ("response_updates", "expected_power", "expect_energy_usage_removed"), + [ + pytest.param( + { + "get_current_power": {"current_power": 2.002}, + "get_energy_usage": SmartErrorCode.JSON_DECODE_FAIL_ERROR, + }, + 2.002, + True, + id="falls_back_to_get_current_power", + ), + pytest.param( + { + "get_energy_usage": _energy_usage(), + "get_current_power": SmartErrorCode.PARAMS_ERROR, + }, + None, + False, + id="returns_none_without_current_power", + ), + ], +) +async def test_v2_current_power_fallbacks( + dev: SmartDevice, + response_updates: dict[str, object], + expected_power: float | None, + expect_energy_usage_removed: bool, +) -> None: + """Test fallback behavior when higher precision power sources are unavailable.""" + energy_module = _get_v2_energy_module(dev) + resp = _copy_last_update(dev, "get_emeter_data") + resp.update(response_updates) + + with patch.object(dev.protocol, "query", return_value=resp): + await dev.update() + + if expect_energy_usage_removed: + assert "get_energy_usage" not in energy_module.data + + assert energy_module.current_consumption == expected_power + assert energy_module.status.power == expected_power + + +@has_emeter_smart +@pytest.mark.parametrize( + ("responses", "expected_calls", "expected_power"), + [ + pytest.param( + { + "get_emeter_data": { + "current_ma": 25, + "energy_wh": 321, + "power_mw": 3003, + "voltage_mv": 120456, + } + }, + ["get_emeter_data"], + 3.003, + id="prefers_get_emeter_data", + ), + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.PARAMS_ERROR + ), + "get_energy_usage": _energy_usage(current_power=2002), + }, + ["get_emeter_data", "get_energy_usage"], + 2.002, + id="falls_back_to_get_energy_usage", + ), + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.UNKNOWN_METHOD_ERROR + ), + "get_energy_usage": _energy_usage(current_power=2002), + }, + ["get_emeter_data", "get_energy_usage"], + 2.002, + id="falls_back_after_unknown_method", + ), + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.PARAMS_ERROR + ), + "get_energy_usage": _energy_usage(), + "get_current_power": {"current_power": 2.002}, + }, + ["get_emeter_data", "get_energy_usage", "get_current_power"], + 2.002, + id="falls_back_to_get_current_power", + ), + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.PARAMS_ERROR + ), + "get_energy_usage": _device_error( + "get_energy_usage", SmartErrorCode.JSON_DECODE_FAIL_ERROR + ), + "get_current_power": {"current_power": 2.002}, + }, + ["get_emeter_data", "get_energy_usage", "get_current_power"], + 2.002, + id="continues_after_get_energy_usage_error", + ), + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.PARAMS_ERROR + ), + "get_energy_usage": _energy_usage(), + "get_current_power": _device_error( + "get_current_power", SmartErrorCode.PARAMS_ERROR + ), + }, + ["get_emeter_data", "get_energy_usage", "get_current_power"], + None, + id="returns_none_when_all_sources_fail", + ), + ], +) +async def test_v2_get_status_current_power_sources( + dev: SmartDevice, + responses: dict[str, object], + expected_calls: list[str], + expected_power: float | None, +) -> None: + """get_status should use the same source precedence as cached status.""" + energy_module = _get_v2_energy_module(dev) + calls: list[str] = [] + + with patch.object(dev.protocol, "query", side_effect=_mock_query(responses, calls)): + status = await energy_module.get_status() + + assert calls == expected_calls + assert status.power == expected_power + + +@has_emeter_smart +@pytest.mark.parametrize( + ("responses", "expected_calls", "expected_match"), + [ + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.JSON_DECODE_FAIL_ERROR + ), + }, + ["get_emeter_data"], + "get_emeter_data", + id="get_emeter_data", + ), + pytest.param( + { + "get_emeter_data": _device_error( + "get_emeter_data", SmartErrorCode.PARAMS_ERROR + ), + "get_energy_usage": _energy_usage(), + "get_current_power": _device_error( + "get_current_power", SmartErrorCode.JSON_DECODE_FAIL_ERROR + ), + }, + ["get_emeter_data", "get_energy_usage", "get_current_power"], + "get_current_power", + id="get_current_power", + ), + ], +) +async def test_v2_get_status_raises_unexpected_optional_method_errors( + dev: SmartDevice, + responses: dict[str, object], + expected_calls: list[str], + expected_match: str, +) -> None: + """Only unsupported optional method errors should fall back silently.""" + energy_module = _get_v2_energy_module(dev) + calls: list[str] = [] + + with ( + patch.object(dev.protocol, "query", side_effect=_mock_query(responses, calls)), + pytest.raises(DeviceError, match=expected_match), + ): + await energy_module.get_status() + + assert calls == expected_calls + + +@p110_v1_smart +async def test_v1_get_status_uses_energy_usage_only(dev: SmartDevice) -> None: + """V1 devices should return energy usage data without querying current power.""" + energy_module = _get_energy_module(dev) + calls: list[str] = [] + + with patch.object( + dev.protocol, + "query", + side_effect=_mock_query({"get_energy_usage": _energy_usage()}, calls), + ): + status = await energy_module.get_status() + + assert calls == ["get_energy_usage"] + assert status.power is None + + +@p110_v1_smart +async def test_v1_get_status_raises_on_get_energy_usage_error( + dev: SmartDevice, +) -> None: + """V1 devices should still raise when get_energy_usage fails.""" + energy_module = _get_energy_module(dev) + + async def query(request: dict[str, dict | None]) -> dict[str, dict]: + method = next(iter(request)) + raise DeviceError(method, error_code=SmartErrorCode.JSON_DECODE_FAIL_ERROR) + + with ( + patch.object(dev.protocol, "query", side_effect=query), + pytest.raises(DeviceError, match="get_energy_usage"), + ): + await energy_module.get_status() + + +@s515d_smart +async def test_s515d_missing_get_current_power_is_optional(dev: SmartDevice) -> None: + """S515D exposes current power without a working get_current_power query.""" + energy_module = _get_v2_energy_module(dev) + + assert energy_module.disabled is False + assert energy_module._last_update_error is None + assert "get_current_power" not in energy_module.data + assert energy_module.current_consumption == 0.0 + assert energy_module.status.power == 0.0 diff --git a/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py index 9a6878e5b..dc3575dce 100644 --- a/tests/smart/modules/test_fan.py +++ b/tests/smart/modules/test_fan.py @@ -11,7 +11,7 @@ @fan -async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture) -> None: """Test fan speed feature.""" fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan @@ -35,7 +35,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): +async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture) -> None: """Test sleep mode feature.""" fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan @@ -53,12 +53,12 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_module(dev: SmartDevice, mocker: MockerFixture) -> None: """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) fan = next(get_parent_and_child_modules(dev, Module.Fan)) assert fan - device = fan._device + device = fan.device await fan.set_fan_speed_level(1) await dev.update() @@ -89,7 +89,7 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_fan_features(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_features(dev: SmartDevice, mocker: MockerFixture) -> None: """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) fan = next(get_parent_and_child_modules(dev, Module.Fan)) diff --git a/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py index e3fe5bb36..220c4dc2b 100644 --- a/tests/smart/modules/test_firmware.py +++ b/tests/smart/modules/test_firmware.py @@ -2,7 +2,7 @@ import asyncio import logging -from contextlib import nullcontext +from contextlib import AbstractContextManager, nullcontext from datetime import date from typing import TypedDict @@ -31,9 +31,14 @@ ], ) async def test_firmware_features( - dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture -): - """Test light effect.""" + dev: SmartDevice, + feature: str, + prop_name: str, + type: type, + required_version: int, + mocker: MockerFixture, +) -> None: + """Test firmware features.""" fw = dev.modules.get(Module.Firmware) assert fw assert fw.firmware_update_info is None @@ -54,7 +59,7 @@ async def test_firmware_features( @firmware -async def test_firmware_update_info(dev: SmartDevice): +async def test_firmware_update_info(dev: SmartDevice) -> None: """Test that the firmware UpdateInfo object deserializes correctly.""" fw = dev.modules.get(Module.Firmware) assert fw @@ -68,7 +73,7 @@ async def test_firmware_update_info(dev: SmartDevice): @firmware -async def test_update_available_without_cloud(dev: SmartDevice): +async def test_update_available_without_cloud(dev: SmartDevice) -> None: """Test that update_available returns None when disconnected.""" fw = dev.modules.get(Module.Firmware) assert fw @@ -95,9 +100,9 @@ async def test_firmware_update( dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, - update_available, - expected_result, -): + update_available: bool, + expected_result: AbstractContextManager, +) -> None: """Test updating firmware.""" caplog.set_level(logging.INFO) diff --git a/tests/smart/modules/test_homekit.py b/tests/smart/modules/test_homekit.py new file mode 100644 index 000000000..745053ca6 --- /dev/null +++ b/tests/smart/modules/test_homekit.py @@ -0,0 +1,16 @@ +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import parametrize + +homekit = parametrize( + "has homekit", component_filter="homekit", protocol_filter={"SMART"} +) + + +@homekit +async def test_info(dev: SmartDevice) -> None: + """Test homekit info.""" + homekit = dev.modules.get(Module.HomeKit) + assert homekit + assert homekit.info diff --git a/tests/smart/modules/test_humidity.py b/tests/smart/modules/test_humidity.py index 5e14a05b4..f5df226ba 100644 --- a/tests/smart/modules/test_humidity.py +++ b/tests/smart/modules/test_humidity.py @@ -1,6 +1,7 @@ import pytest -from kasa.smart.modules import HumiditySensor +from kasa import Module +from kasa.smart import SmartDevice from ...device_fixtures import parametrize @@ -17,9 +18,9 @@ ("humidity_warning", bool), ], ) -async def test_humidity_features(dev, feature, type): +async def test_humidity_features(dev: SmartDevice, feature: str, type: type) -> None: """Test that features are registered and work as expected.""" - humidity: HumiditySensor = dev.modules["HumiditySensor"] + humidity = dev.modules[Module.HumiditySensor] prop = getattr(humidity, feature) assert isinstance(prop, type) diff --git a/tests/smart/modules/test_light_effect.py b/tests/smart/modules/test_light_effect.py index e4475652c..0e5225033 100644 --- a/tests/smart/modules/test_light_effect.py +++ b/tests/smart/modules/test_light_effect.py @@ -16,7 +16,7 @@ @light_effect -async def test_light_effect(dev: Device, mocker: MockerFixture): +async def test_light_effect(dev: Device, mocker: MockerFixture) -> None: """Test light effect.""" light_effect = dev.modules.get(Module.LightEffect) assert isinstance(light_effect, LightEffect) @@ -46,7 +46,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): @pytest.mark.parametrize("effect_active", [True, False]) async def test_light_effect_brightness( dev: Device, effect_active: bool, mocker: MockerFixture -): +) -> None: """Test that light module uses light_effect for brightness when active.""" light_module = dev.modules[Module.Light] diff --git a/tests/smart/modules/test_light_strip_effect.py b/tests/smart/modules/test_light_strip_effect.py index 81bc35c83..7cd5481ef 100644 --- a/tests/smart/modules/test_light_strip_effect.py +++ b/tests/smart/modules/test_light_strip_effect.py @@ -18,7 +18,7 @@ @light_strip_effect -async def test_light_strip_effect(dev: Device, mocker: MockerFixture): +async def test_light_strip_effect(dev: Device, mocker: MockerFixture) -> None: """Test light strip effect.""" light_effect = dev.modules.get(Module.LightEffect) @@ -63,7 +63,7 @@ async def test_light_strip_effect(dev: Device, mocker: MockerFixture): @pytest.mark.parametrize("effect_active", [True, False]) async def test_light_effect_brightness( dev: Device, effect_active: bool, mocker: MockerFixture -): +) -> None: """Test that light module uses light_effect for brightness when active.""" light_module = dev.modules[Module.Light] diff --git a/tests/smart/modules/test_lighttransition.py b/tests/smart/modules/test_lighttransition.py index c1b805e48..2ef5a9e74 100644 --- a/tests/smart/modules/test_lighttransition.py +++ b/tests/smart/modules/test_lighttransition.py @@ -23,7 +23,7 @@ @light_transition_v1 -async def test_module_v1(dev: SmartDevice, mocker: MockerFixture): +async def test_module_v1(dev: SmartDevice, mocker: MockerFixture) -> None: """Test light transition module.""" assert isinstance(dev, SmartDevice) light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) @@ -42,7 +42,7 @@ async def test_module_v1(dev: SmartDevice, mocker: MockerFixture): @light_transition_gt_v1 -async def test_module_gt_v1(dev: SmartDevice, mocker: MockerFixture): +async def test_module_gt_v1(dev: SmartDevice, mocker: MockerFixture) -> None: """Test light transition module.""" assert isinstance(dev, SmartDevice) light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) diff --git a/tests/smart/modules/test_matter.py b/tests/smart/modules/test_matter.py new file mode 100644 index 000000000..43f872c06 --- /dev/null +++ b/tests/smart/modules/test_matter.py @@ -0,0 +1,20 @@ +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import parametrize + +matter = parametrize( + "has matter", component_filter="matter", protocol_filter={"SMART", "SMARTCAM"} +) + + +@matter +async def test_info(dev: SmartDevice) -> None: + """Test matter info.""" + matter = dev.modules.get(Module.Matter) + assert matter + assert matter.info + setup_code = dev.features.get("matter_setup_code") + assert setup_code + setup_payload = dev.features.get("matter_setup_payload") + assert setup_payload diff --git a/tests/smart/modules/test_mop.py b/tests/smart/modules/test_mop.py new file mode 100644 index 000000000..566a62f8e --- /dev/null +++ b/tests/smart/modules/test_mop.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules.mop import Waterlevel + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +mop = parametrize("has mop", component_filter="mop", protocol_filter={"SMART"}) + + +@mop +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("mop_attached", "mop_attached", bool), + ("mop_waterlevel", "waterlevel", str), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + mod = next(get_parent_and_child_modules(dev, Module.Mop)) + assert mod is not None + + prop = getattr(mod, prop_name) + assert isinstance(prop, type) + + feat = mod._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@mop +async def test_mop_waterlevel(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test dust mode.""" + mop_module = next(get_parent_and_child_modules(dev, Module.Mop)) + call = mocker.spy(mop_module, "call") + + waterlevel = mop_module._device.features["mop_waterlevel"] + assert mop_module.waterlevel == waterlevel.value + + new_level = Waterlevel.High + await mop_module.set_waterlevel(new_level.name) + + call.assert_called_with( + "setCleanAttr", {"cistern": new_level.value, "type": "global"} + ) + + await dev.update() + + assert mop_module.waterlevel == new_level.name + + with pytest.raises(ValueError, match="Invalid waterlevel"): + await mop_module.set_waterlevel("invalid") diff --git a/tests/smart/modules/test_motionsensor.py b/tests/smart/modules/test_motionsensor.py index 418ad51a1..80f76586d 100644 --- a/tests/smart/modules/test_motionsensor.py +++ b/tests/smart/modules/test_motionsensor.py @@ -16,7 +16,7 @@ ("motion_detected", bool), ], ) -async def test_motion_features(dev: Device, feature, type): +async def test_motion_features(dev: Device, feature: str, type: type) -> None: """Test that features are registered and work as expected.""" motion = dev.modules.get(Module.MotionSensor) assert motion is not None diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py new file mode 100644 index 000000000..db0a5d933 --- /dev/null +++ b/tests/smart/modules/test_powerprotection.py @@ -0,0 +1,109 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +powerprotection = parametrize( + "has powerprotection", + component_filter="power_protection", + protocol_filter={"SMART"}, +) + + +@powerprotection +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("overloaded", "overloaded", bool), + ("power_protection_threshold", "protection_threshold", int), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) + assert powerprot + device = powerprot._device + + prop = getattr(powerprot, prop_name) + assert isinstance(prop, type) + + feat = device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@powerprotection +async def test_set_enable(dev: Device, mocker: MockerFixture) -> None: + """Test enable.""" + powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) + assert powerprot + device = powerprot._device + + original_enabled = powerprot.enabled + original_threshold = powerprot.protection_threshold + + try: + # Simple enable with an existing threshold + call_spy = mocker.spy(powerprot, "call") + await powerprot.set_enabled(True) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + + enabled_key = next( + k for k in powerprot.data["get_protection_power"] if "enabled" in k + ) + assert params[enabled_key] is True + assert params["protection_power"] is not None + + # Enable with no threshold param when 0 + call_spy.reset_mock() + await powerprot.set_protection_threshold(0) + await device.update() + await powerprot.set_enabled(True) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + assert "enabled" in params or "protection_enabled" in params + assert params["protection_power"] == int(powerprot._max_power / 2) + + # Enable false should not update the threshold + call_spy.reset_mock() + await powerprot.set_protection_threshold(0) + await device.update() + await powerprot.set_enabled(False) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + assert "enabled" in params or "protection_enabled" in params + assert params["protection_power"] == 0 + + finally: + await powerprot.set_enabled(original_enabled, threshold=original_threshold) + + +@powerprotection +async def test_set_threshold(dev: Device, mocker: MockerFixture) -> None: + """Test enable.""" + powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) + assert powerprot + + call_spy = mocker.spy(powerprot, "call") + await powerprot.set_protection_threshold(123) + + args, kwargs = call_spy.call_args + method, params = args + assert method == "set_protection_power" + assert "enabled" in params or "protection_enabled" in params + assert params["protection_power"] == 123 + + with pytest.raises(ValueError, match="Threshold out of range"): + await powerprot.set_protection_threshold(-10) diff --git a/tests/smart/modules/test_speaker.py b/tests/smart/modules/test_speaker.py new file mode 100644 index 000000000..b0aef2654 --- /dev/null +++ b/tests/smart/modules/test_speaker.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +speaker = parametrize( + "has speaker", component_filter="speaker", protocol_filter={"SMART"} +) + + +@speaker +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("volume", "volume", int), + ], +) +async def test_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +) -> None: + """Test that features are registered and work as expected.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + assert speaker is not None + + prop = getattr(speaker, prop_name) + assert isinstance(prop, type) + + feat = speaker._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@speaker +async def test_set_volume(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test speaker settings.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + assert speaker is not None + + call = mocker.spy(speaker, "call") + + volume = speaker._device.features["volume"] + assert speaker.volume == volume.value + + new_volume = 15 + await speaker.set_volume(new_volume) + + call.assert_called_with("setVolume", {"volume": new_volume}) + + await dev.update() + + assert speaker.volume == new_volume + + with pytest.raises(ValueError, match="Volume must be between 0 and 100"): + await speaker.set_volume(-10) + + with pytest.raises(ValueError, match="Volume must be between 0 and 100"): + await speaker.set_volume(110) + + +@speaker +async def test_locate(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test the locate method.""" + speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) + call = mocker.spy(speaker, "call") + + await speaker.locate() + + call.assert_called_with("playSelectAudio", {"audio_type": "seek_me"}) diff --git a/tests/smart/modules/test_temperature.py b/tests/smart/modules/test_temperature.py index c2f91ae1d..814b07c4f 100644 --- a/tests/smart/modules/test_temperature.py +++ b/tests/smart/modules/test_temperature.py @@ -1,6 +1,7 @@ import pytest -from kasa.smart.modules import TemperatureSensor +from kasa import Module +from kasa.smart import SmartDevice from ...device_fixtures import parametrize @@ -23,9 +24,9 @@ ("temperature_unit", str), ], ) -async def test_temperature_features(dev, feature, type): +async def test_temperature_features(dev: SmartDevice, feature: str, type: type) -> None: """Test that features are registered and work as expected.""" - temp_module: TemperatureSensor = dev.modules["TemperatureSensor"] + temp_module = dev.modules[Module.TemperatureSensor] prop = getattr(temp_module, feature) assert isinstance(prop, type) @@ -36,9 +37,9 @@ async def test_temperature_features(dev, feature, type): @temperature_warning -async def test_temperature_warning(dev): +async def test_temperature_warning(dev: SmartDevice) -> None: """Test that features are registered and work as expected.""" - temp_module: TemperatureSensor = dev.modules["TemperatureSensor"] + temp_module = dev.modules[Module.TemperatureSensor] assert hasattr(temp_module, "temperature_warning") assert isinstance(temp_module.temperature_warning, bool) diff --git a/tests/smart/modules/test_temperaturecontrol.py b/tests/smart/modules/test_temperaturecontrol.py index d47f19ee6..49118df0c 100644 --- a/tests/smart/modules/test_temperaturecontrol.py +++ b/tests/smart/modules/test_temperaturecontrol.py @@ -3,7 +3,8 @@ import pytest -from kasa.smart.modules import TemperatureControl +from kasa import Module +from kasa.smart import SmartDevice from kasa.smart.modules.temperaturecontrol import ThermostatState from ...device_fixtures import parametrize, thermostats_smart @@ -23,9 +24,11 @@ ("temperature_offset", int), ], ) -async def test_temperature_control_features(dev, feature, type): +async def test_temperature_control_features( + dev: SmartDevice, feature: str, type: type +) -> None: """Test that features are registered and work as expected.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] prop = getattr(temp_module, feature) assert isinstance(prop, type) @@ -40,9 +43,9 @@ async def test_temperature_control_features(dev, feature, type): @thermostats_smart -async def test_set_temperature_turns_heating_on(dev): +async def test_set_temperature_turns_heating_on(dev: SmartDevice) -> None: """Test that set_temperature turns heating on.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] await temp_module.set_state(False) await dev.update() @@ -57,9 +60,9 @@ async def test_set_temperature_turns_heating_on(dev): @thermostats_smart -async def test_set_temperature_invalid_values(dev): +async def test_set_temperature_invalid_values(dev: SmartDevice) -> None: """Test that out-of-bounds temperature values raise errors.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] with pytest.raises( ValueError, match="Invalid target temperature -1, must be in range" @@ -73,9 +76,9 @@ async def test_set_temperature_invalid_values(dev): @thermostats_smart -async def test_temperature_offset(dev): +async def test_temperature_offset(dev: SmartDevice) -> None: """Test the temperature offset API.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] with pytest.raises( ValueError, match=re.escape("Temperature offset must be [-10, 10]") ): @@ -111,9 +114,11 @@ async def test_temperature_offset(dev): pytest.param(ThermostatState.Unknown, ["invalid"], False, id="unknown state"), ], ) -async def test_thermostat_mode(dev, mode, states, frost_protection): +async def test_thermostat_mode( + dev: SmartDevice, mode: ThermostatState, states: list[str], frost_protection: bool +) -> None: """Test different thermostat modes.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] temp_module.data["frost_protection_on"] = frost_protection temp_module.data["trv_states"] = states @@ -138,9 +143,15 @@ async def test_thermostat_mode(dev, mode, states, frost_protection): ], ) @pytest.mark.xdist_group(name="caplog") -async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): +async def test_thermostat_mode_warnings( + dev: SmartDevice, + mode: ThermostatState, + states: list[str], + msg: str, + caplog: pytest.LogCaptureFixture, +) -> None: """Test thermostat modes that should log a warning.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] caplog.set_level(logging.WARNING) temp_module.data["trv_states"] = states @@ -149,8 +160,20 @@ async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): @thermostats_smart -async def test_thermostat_heating_with_low_battery(dev): +async def test_thermostat_heating_with_low_battery(dev: SmartDevice) -> None: """Test that mode is reported correctly with extra states.""" - temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module = dev.modules[Module.TemperatureControl] temp_module.data["trv_states"] = ["low_battery", "heating"] assert temp_module.mode is ThermostatState.Heating + + +@thermostats_smart +async def test_thermostat_idle_with_low_battery( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +) -> None: + """Test that mode is reported correctly with extra states.""" + temp_module = dev.modules[Module.TemperatureControl] + temp_module.data["trv_states"] = ["low_battery"] + with caplog.at_level(logging.WARNING): + assert temp_module.mode is ThermostatState.Idle + assert not caplog.records diff --git a/tests/smart/modules/test_triggerlogs.py b/tests/smart/modules/test_triggerlogs.py index c1d957217..f6aebcc03 100644 --- a/tests/smart/modules/test_triggerlogs.py +++ b/tests/smart/modules/test_triggerlogs.py @@ -10,7 +10,7 @@ @triggerlogs -async def test_trigger_logs(dev: Device): +async def test_trigger_logs(dev: Device) -> None: """Test that features are registered and work as expected.""" triggerlogs = dev.modules.get(Module.TriggerLogs) assert triggerlogs is not None diff --git a/tests/smart/modules/test_waterleak.py b/tests/smart/modules/test_waterleak.py index 1821e6e07..f7b94a8da 100644 --- a/tests/smart/modules/test_waterleak.py +++ b/tests/smart/modules/test_waterleak.py @@ -3,8 +3,10 @@ import pytest -from kasa.smart.modules import WaterleakSensor +from kasa import Module +from kasa.smart import SmartDevice +from ...conftest import get_device_for_fixture_protocol from ...device_fixtures import parametrize waterleak = parametrize( @@ -12,6 +14,12 @@ ) +@pytest.fixture +async def parent(request): + """Get a dummy parent for tz tests.""" + return await get_device_for_fixture_protocol("H100(EU)_1.0_1.5.5.json", "SMART") + + @waterleak @pytest.mark.parametrize( ("feature", "prop_name", "type"), @@ -21,9 +29,12 @@ ("water_leak", "status", Enum), ], ) -async def test_waterleak_properties(dev, feature, prop_name, type): +async def test_waterleak_properties( + dev: SmartDevice, parent: SmartDevice, feature: str, prop_name: str, type: type +) -> None: """Test that features are registered and work as expected.""" - waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] + dev._parent = parent + waterleak = dev.modules[Module.WaterleakSensor] prop = getattr(waterleak, prop_name) assert isinstance(prop, type) @@ -34,12 +45,16 @@ async def test_waterleak_properties(dev, feature, prop_name, type): @waterleak -async def test_waterleak_features(dev): +async def test_waterleak_features(dev: SmartDevice, parent: SmartDevice) -> None: """Test waterleak features.""" - waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] + dev._parent = parent + waterleak = dev.modules[Module.WaterleakSensor] assert "water_leak" in dev.features assert dev.features["water_leak"].value == waterleak.status assert "water_alert" in dev.features assert dev.features["water_alert"].value == waterleak.alert + + assert "water_alert_timestamp" in dev.features + assert dev.features["water_alert_timestamp"].value == waterleak.alert_timestamp diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py new file mode 100644 index 000000000..aea73381a --- /dev/null +++ b/tests/smart/test_smartdevice.py @@ -0,0 +1,1017 @@ +"""Tests for SMART devices.""" + +from __future__ import annotations + +import copy +import logging +import time +from collections import OrderedDict +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import patch + +import pytest +from freezegun.api import FrozenDateTimeFactory +from pytest_mock import MockerFixture + +from kasa import Device, DeviceType, KasaException, Module +from kasa.exceptions import DeviceError, SmartErrorCode +from kasa.smart import SmartDevice +from kasa.smart.modules.energy import Energy +from kasa.smart.smartmodule import SmartModule +from kasa.smartcam import SmartCamDevice +from tests.conftest import ( + DISCOVERY_MOCK_IP, + device_smart, + get_device_for_fixture_protocol, + get_parent_and_child_modules, + smart_discovery, +) +from tests.device_fixtures import ( + hub_smartcam, + hubs_smart, + parametrize_combine, + variable_temp_smart, +) + +from ..fakeprotocol_smart import FakeSmartTransport +from ..fakeprotocol_smartcam import FakeSmartCamTransport + +DUMMY_CHILD_REQUEST_PREFIX = "get_dummy_" + +hub_all = parametrize_combine([hubs_smart, hub_smartcam]) + + +@device_smart +@pytest.mark.requires_dummy +@pytest.mark.xdist_group(name="caplog") +async def test_try_get_response( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +) -> None: + mock_response: dict = { + "get_device_info": SmartErrorCode.PARAMS_ERROR, + } + caplog.set_level(logging.DEBUG) + dev._try_get_response(mock_response, "get_device_info", {}) + msg = "Error PARAMS_ERROR(-1008) getting request get_device_info for device 127.0.0.123" + assert msg in caplog.text + + +@device_smart +@pytest.mark.requires_dummy +async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture) -> None: + mock_response: dict = { + "get_device_usage": {}, + "get_device_time": {}, + } + msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" + mocker.patch.object(dev.protocol, "query", return_value=mock_response) + with pytest.raises(KasaException, match=msg): + await dev.update() + + +@smart_discovery +async def test_device_type_no_update( + discovery_mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test device type and repr when device not updated.""" + dev = SmartDevice(DISCOVERY_MOCK_IP) + assert dev.device_type is DeviceType.Unknown + assert repr(dev) == f"" + + discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"]) + + disco_model = discovery_result["device_model"] + short_model, _, _ = disco_model.partition("(") + dev.update_from_discover_info(discovery_result) + assert dev.device_type is DeviceType.Unknown + assert ( + repr(dev) + == f"" + ) + discovery_result["device_type"] = "SMART.FOOBAR" + dev.update_from_discover_info(discovery_result) + dev._components = {"dummy": 1} + assert dev.device_type is DeviceType.Plug + assert ( + repr(dev) + == f"" + ) + assert "Unknown device type, falling back to plug" in caplog.text + + +@device_smart +async def test_initial_update(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test the initial update cycle.""" + # As the fixture data is already initialized, we reset the state for testing + dev._components_raw = None + dev._components = {} + dev._modules = OrderedDict() + dev._features = {} + dev._children = {} + dev._last_update = {} + dev._last_update_time = None + + negotiate = mocker.spy(dev, "_negotiate") + initialize_modules = mocker.spy(dev, "_initialize_modules") + initialize_features = mocker.spy(dev, "_initialize_features") + + # Perform two updates and verify that initialization is only done once + await dev.update() + await dev.update() + + negotiate.assert_called_once() + assert dev._components_raw is not None + initialize_modules.assert_called_once() + assert dev.modules + initialize_features.assert_called_once() + assert dev.features + + +@device_smart +async def test_negotiate(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test that the initial negotiation performs expected steps.""" + # As the fixture data is already initialized, we reset the state for testing + dev._components_raw = None + dev._children = {} + + query = mocker.spy(dev.protocol, "query") + initialize_children = mocker.spy(dev, "_initialize_children") + await dev._negotiate() + + # Check that we got the initial negotiation call + query.assert_any_call( + { + "component_nego": None, + "get_device_info": None, + "get_connect_cloud_state": None, + } + ) + assert dev._components_raw + + # Check the children are created, if device supports them + if "child_device" in dev._components: + initialize_children.assert_called_once() + query.assert_any_call( + { + "get_child_device_component_list": None, + "get_child_device_list": None, + } + ) + await dev.update() + assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"] + + +@device_smart +async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture) -> None: + """Test that the regular update uses queries from all supported modules.""" + # We need to have some modules initialized by now + assert dev._modules + # Reset last update so all modules will query + for mod in dev._modules.values(): + mod._last_update_time = None + + device_queries: dict[SmartDevice, dict[str, Any]] = {} + for mod in dev._modules.values(): + device_queries.setdefault(mod._device, {}).update(mod.query()) + # Hubs do not query child modules by default. + if dev.device_type != Device.Type.Hub: + for child in dev.children: + for mod in child.modules.values(): + device_queries.setdefault(mod._device, {}).update(mod.query()) + + spies = {} + for device in device_queries: + spies[device] = mocker.spy(device.protocol, "query") + + await dev.update() + for device in device_queries: + if device_queries[device]: + # Need assert any here because the child device updates use the parent's protocol + spies[device].assert_any_call(device_queries[device]) + else: + spies[device].assert_not_called() + + +@device_smart +@pytest.mark.xdist_group(name="caplog") +async def test_update_module_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that modules with minimum delays delay.""" + # We need to have some modules initialized by now + assert dev._modules + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + await new_dev.update() + first_update_time = time.monotonic() + assert new_dev._last_update_time == first_update_time + for module in new_dev.modules.values(): + if module.query(): + assert module._last_update_time == first_update_time + + seconds = 0 + tick = 30 + while seconds <= 180: + seconds += tick + freezer.tick(tick) + + now = time.monotonic() + await new_dev.update() + for module in new_dev.modules.values(): + mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS + if module.query(): + expected_update_time = ( + now if mod_delay == 0 else now - (seconds % mod_delay) + ) + + assert module._last_update_time == expected_update_time, ( + f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + ) + + +async def _get_child_responses(child_requests: list[dict[str, Any]], child_protocol): + """Get dummy responses for testing all child modules. + + Even if they don't return really return query. + """ + child_req = {item["method"]: item.get("params") for item in child_requests} + child_resp = {k: v for k, v in child_req.items() if k.startswith("get_dummy")} + child_req = { + k: v for k, v in child_req.items() if k.startswith("get_dummy") is False + } + resp = await child_protocol._query(child_req) + resp = {**child_resp, **resp} + return [ + {"method": k, "error_code": 0, "result": v or {"dummy": "dummy"}} + for k, v in resp.items() + ] + + +@hub_all +@pytest.mark.xdist_group(name="caplog") +async def test_hub_children_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that hub children use the correct delay.""" + if not dev.children: + pytest.skip(f"Device {dev.model} does not have children.") + # We need to have some modules initialized by now + assert dev._modules + + new_dev = type(dev)("127.0.0.1", protocol=dev.protocol) + module_queries: dict[str, dict[str, dict]] = {} + + # children should always update on first update + await new_dev.update(update_children=False) + + if TYPE_CHECKING: + from ..fakeprotocol_smart import FakeSmartTransport + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + if dev.protocol._transport.child_protocols: + for child in new_dev.children: + for modname, module in child._modules.items(): + if ( + not (q := module.query()) + and modname not in {"DeviceModule", "Light", "Battery", "Camera"} + and not module.SYSINFO_LOOKUP_KEYS + ): + q = {f"get_dummy_{modname}": {}} + mocker.patch.object(module, "query", return_value=q) + if q: + queries = module_queries.setdefault(child.device_id, {}) + queries[cast(str, modname)] = q + module._last_update_time = None + + module_queries[""] = { + cast(str, modname): q + for modname, module in dev._modules.items() + if (q := module.query()) + } + + async def _query(request, *args, **kwargs): + # If this is a child multipleRequest query return the error wrapped + child_id = None + # smart hub + if ( + (cc := request.get("control_child")) + and (child_id := cc.get("device_id")) + and (requestData := cc["requestData"]) + and requestData["method"] == "multipleRequest" + and (child_requests := requestData["params"]["requests"]) + ): + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp = await _get_child_responses(child_requests, child_protocol) + return {"control_child": {"responseData": {"result": {"responses": resp}}}} + # smartcam hub + if ( + (mr := request.get("multipleRequest")) + and (requests := mr.get("requests")) + # assumes all requests for the same child + and ( + child_id := next(iter(requests)) + .get("params", {}) + .get("childControl", {}) + .get("device_id") + ) + and ( + child_requests := [ + cc["request_data"] + for req in requests + if (cc := req["params"].get("childControl")) + ] + ) + ): + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp = await _get_child_responses(child_requests, child_protocol) + resp = [{"result": {"response_data": resp}} for resp in resp] + return {"multipleRequest": {"responses": resp}} + + if child_id: # child single query + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp_list = await _get_child_responses([requestData], child_protocol) + resp = {"control_child": {"responseData": resp_list[0]}} + else: + resp = await dev.protocol._query(request, *args, **kwargs) + + return resp + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + first_update_time = time.monotonic() + assert new_dev._last_update_time == first_update_time + + await new_dev.update() + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + assert mod._last_update_time == first_update_time + + for mod in new_dev.modules.values(): + mod.MINIMUM_UPDATE_INTERVAL_SECS = 5 + freezer.tick(180) + + now = time.monotonic() + await new_dev.update() + + child_tick = max( + module.MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS + for child in new_dev.children + for module in child.modules.values() + ) + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + if modname in {"Firmware"}: + continue + mod = cast(SmartModule, check_dev.modules[modname]) + expected_update_time = first_update_time if dev_id else now + assert mod._last_update_time == expected_update_time + + freezer.tick(child_tick) + + now = time.monotonic() + await new_dev.update() + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + if modname in {"Firmware"}: + continue + mod = cast(SmartModule, check_dev.modules[modname]) + + assert mod._last_update_time == now + + +@pytest.mark.parametrize( + ("first_update"), + [ + pytest.param(True, id="First update true"), + pytest.param(False, id="First update false"), + ], +) +@pytest.mark.parametrize( + ("error_type"), + [ + pytest.param(SmartErrorCode.PARAMS_ERROR, id="Device error"), + pytest.param(TimeoutError("Dummy timeout"), id="Query error"), + ], +) +@pytest.mark.parametrize( + ("recover"), + [ + pytest.param(True, id="recover"), + pytest.param(False, id="no recover"), + ], +) +@device_smart +@pytest.mark.xdist_group(name="caplog") +async def test_update_module_query_errors( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + first_update: bool, + error_type: SmartErrorCode | TimeoutError, + recover: bool, +) -> None: + """Test that modules that disabled / removed on query failures. + + i.e. the whole query times out rather than device returns an error. + """ + caplog.set_level(logging.DEBUG) + # We need to have some modules initialized by now + assert dev._modules + + SmartModule.DISABLE_AFTER_ERROR_COUNT = 2 + first_update_queries = {"get_device_info", "get_connect_cloud_state"} + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + if not first_update: + await new_dev.update() + freezer.tick(max(module.update_interval for module in dev._modules.values())) + + module_queries: dict[str, dict[str, dict]] = {} + if TYPE_CHECKING: + from ..fakeprotocol_smart import FakeSmartTransport + + assert isinstance(dev.protocol._transport, FakeSmartTransport) + if dev.protocol._transport.child_protocols: + for child in new_dev.children: + for modname, module in child._modules.items(): + if ( + not (q := module.query()) + and modname not in {"DeviceModule", "Light"} + and not module.SYSINFO_LOOKUP_KEYS + ): + q = {f"get_dummy_{modname}": {}} + mocker.patch.object(module, "query", return_value=q) + if q: + queries = module_queries.setdefault(child.device_id, {}) + queries[cast(str, modname)] = q + + module_queries[""] = { + cast(str, modname): q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + + raise_error = True + + async def _query(request, *args, **kwargs): + pass + # If this is a childmultipleRequest query return the error wrapped + child_id = None + if ( + (cc := request.get("control_child")) + and (child_id := cc.get("device_id")) + and (requestData := cc["requestData"]) + and requestData["method"] == "multipleRequest" + and (child_requests := requestData["params"]["requests"]) + ): + if raise_error: + if not isinstance(error_type, SmartErrorCode): + raise TimeoutError() + if len(child_requests) > 1: + raise TimeoutError() + resp = { + "method": child_requests[0]["method"], + "error_code": error_type.value, + } + else: + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp = await _get_child_responses(child_requests, child_protocol) + return {"control_child": {"responseData": {"result": {"responses": resp}}}} + + if ( + not raise_error + or "component_nego" in request + # allow the initial child device query + or ( + "get_child_device_component_list" in request + and "get_child_device_list" in request + and len(request) == 2 + ) + ): + if child_id: # child single query + child_protocol = dev.protocol._transport.child_protocols[child_id] + resp_list = await _get_child_responses([requestData], child_protocol) + resp = {"control_child": {"responseData": resp_list[0]}} + else: + resp = await dev.protocol._query(request, *args, **kwargs) + if raise_error: + resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR + return resp + + # Don't test for errors on get_device_info as that is likely terminal + if len(request) == 1 and "get_device_info" in request: + return await dev.protocol._query(request, *args, **kwargs) + + if isinstance(error_type, SmartErrorCode): + if len(request) == 1: + raise DeviceError("Dummy device error", error_code=error_type) + raise TimeoutError("Dummy timeout") + raise error_type + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + await new_dev.update() + + msg = f"Error querying {new_dev.host} for modules" + assert msg in caplog.text + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + if modname in {"DeviceModule"} or ( + hasattr(mod, "_state_in_sysinfo") and mod._state_in_sysinfo is True + ): + continue + assert mod.disabled is False, f"{modname} disabled" + assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS + for mod_query in modqueries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + + # Query again should not run for the modules + caplog.clear() + await new_dev.update() + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + + freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS) + + caplog.clear() + + if recover: + raise_error = False + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + if not recover: + assert msg in caplog.text + + for dev_id, modqueries in module_queries.items(): + check_dev = new_dev._children[dev_id] if dev_id else new_dev + for modname in modqueries: + mod = cast(SmartModule, check_dev.modules[modname]) + if modname in {"DeviceModule"} or ( + hasattr(mod, "_state_in_sysinfo") and mod._state_in_sysinfo is True + ): + continue + if not recover: + assert mod.disabled is True, f"{modname} not disabled" + assert mod._error_count == 2 + assert mod._last_update_error + for mod_query in modqueries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + # Test one of the raise_if_update_error + if mod.name == "Energy": + emod = cast(Energy, mod) + with pytest.raises(KasaException, match="Module update error"): + assert emod.status is not None + else: + assert mod.disabled is False + assert mod._error_count == 0 + assert mod._last_update_error is None + # Test one of the raise_if_update_error doesn't raise + if mod.name == "Energy": + emod = cast(Energy, mod) + assert emod.status is not None + + +async def test_get_modules() -> None: + """Test getting modules for child and parent modules.""" + dummy_device = await get_device_for_fixture_protocol( + "KS240(US)_1.0_1.0.5.json", "SMART" + ) + from kasa.smart.modules import Cloud + + # Modules on device + module = dummy_device.modules.get("Cloud") + assert module + assert module.device == dummy_device + assert isinstance(module, Cloud) + + module = dummy_device.modules.get(Module.Cloud) + assert module + assert module._device == dummy_device + assert isinstance(module, Cloud) + + # Modules on child + module = dummy_device.modules.get("Fan") + assert module is None + module = next(get_parent_and_child_modules(dummy_device, "Fan")) + assert module + assert module.device != dummy_device + assert module.device.parent == dummy_device + + # Invalid modules + module = dummy_device.modules.get("DummyModule") + assert module is None + + module = dummy_device.modules.get(Module.IotAmbientLight) + assert module is None + + +@device_smart +async def test_smartdevice_cloud_connection( + dev: SmartDevice, mocker: MockerFixture +) -> None: + """Test is_cloud_connected property.""" + assert isinstance(dev, SmartDevice) + assert "cloud_connect" in dev._components + + is_connected = ( + (cc := dev._last_update.get("get_connect_cloud_state")) + and not isinstance(cc, SmartErrorCode) + and cc["status"] == 0 + ) + + assert dev.is_cloud_connected == is_connected + last_update = dev._last_update + + for child in dev.children: + mocker.patch.object(child.protocol, "query", return_value=child._last_update) + + last_update["get_connect_cloud_state"] = {"status": 0} + with patch.object(dev.protocol, "query", return_value=last_update): + await dev.update() + assert dev.is_cloud_connected is True + + last_update["get_connect_cloud_state"] = {"status": 1} + with patch.object(dev.protocol, "query", return_value=last_update): + await dev.update() + assert dev.is_cloud_connected is False + + last_update["get_connect_cloud_state"] = SmartErrorCode.UNKNOWN_METHOD_ERROR + with patch.object(dev.protocol, "query", return_value=last_update): + await dev.update() + assert dev.is_cloud_connected is False + + # Test for no cloud_connect component during device initialisation + component_list = [ + val + for val in dev._components_raw["component_list"] + if val["id"] not in {"cloud_connect"} + ] + initial_response = { + "component_nego": {"component_list": component_list}, + "get_connect_cloud_state": last_update["get_connect_cloud_state"], + "get_device_info": last_update["get_device_info"], + } + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + first_call = True + + async def side_effect_func(*args, **kwargs): + nonlocal first_call + resp = ( + initial_response + if first_call + else await new_dev.protocol._query(*args, **kwargs) + ) + first_call = False + return resp + + with patch.object( + new_dev.protocol, + "query", + side_effect=side_effect_func, + ): + await new_dev.update() + assert new_dev.is_cloud_connected is False + + +@variable_temp_smart +async def test_smart_temp_range(dev: Device) -> None: + light = dev.modules.get(Module.Light) + assert light + color_temp_feat = light.get_feature("color_temp") + assert color_temp_feat + assert color_temp_feat.range + + +@device_smart +async def test_initialize_modules_sysinfo_lookup_keys( + dev: SmartDevice, mocker: MockerFixture +) -> None: + """Test that matching modules using SYSINFO_LOOKUP_KEYS are initialized correctly.""" + + class AvailableKey(SmartModule): + SYSINFO_LOOKUP_KEYS = ["device_id"] + + class NonExistingKey(SmartModule): + SYSINFO_LOOKUP_KEYS = ["this_does_not_exist"] + + # The __init_subclass__ hook in smartmodule checks the path, + # so we have to manually add these for testing. + mocker.patch.dict( + "kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES", + { + AvailableKey._module_name(): AvailableKey, + NonExistingKey._module_name(): NonExistingKey, + }, + ) + + # We have an already initialized device, so we try to initialize the modules again + await dev._initialize_modules() + + assert "AvailableKey" in dev.modules + assert "NonExistingKey" not in dev.modules + + +@device_smart +async def test_initialize_modules_required_component( + dev: SmartDevice, mocker: MockerFixture +) -> None: + """Test that matching modules using REQUIRED_COMPONENT are initialized correctly.""" + + class AvailableComponent(SmartModule): + REQUIRED_COMPONENT = "device" + + class NonExistingComponent(SmartModule): + REQUIRED_COMPONENT = "this_does_not_exist" + + # The __init_subclass__ hook in smartmodule checks the path, + # so we have to manually add these for testing. + mocker.patch.dict( + "kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES", + { + AvailableComponent._module_name(): AvailableComponent, + NonExistingComponent._module_name(): NonExistingComponent, + }, + ) + + # We have an already initialized device, so we try to initialize the modules again + await dev._initialize_modules() + + assert "AvailableComponent" in dev.modules + assert "NonExistingComponent" not in dev.modules + + +async def test_smartmodule_query() -> None: + """Test that a module that doesn't set QUERY_GETTER_NAME has empty query.""" + + class DummyModule(SmartModule): + pass + + dummy_device = await get_device_for_fixture_protocol( + "KS240(US)_1.0_1.0.5.json", "SMART" + ) + mod = DummyModule(dummy_device, "dummy") + assert mod.query() == {} + + +@hub_all +@pytest.mark.xdist_group(name="caplog") +@pytest.mark.requires_dummy +async def test_dynamic_devices(dev: Device, caplog: pytest.LogCaptureFixture) -> None: + """Test dynamic child devices.""" + if not dev.children: + pytest.skip(f"Device {dev.model} does not have children.") + + transport = dev.protocol._transport + assert isinstance(transport, FakeSmartCamTransport | FakeSmartTransport) + + lu = dev._last_update + assert lu + child_device_info = lu.get("getChildDeviceList", lu.get("get_child_device_list")) + assert child_device_info + + child_device_components = lu.get( + "getChildDeviceComponentList", lu.get("get_child_device_component_list") + ) + assert child_device_components + + mock_child_device_info = copy.deepcopy(child_device_info) + mock_child_device_components = copy.deepcopy(child_device_components) + + first_child = child_device_info["child_device_list"][0] + first_child_device_id = first_child["device_id"] + + first_child_components = next( + iter( + [ + cc + for cc in child_device_components["child_component_list"] + if cc["device_id"] == first_child_device_id + ] + ) + ) + + first_child_fake_transport = transport.child_protocols[first_child_device_id] + + # Test adding devices + start_child_count = len(dev.children) + added_ids = [] + for i in range(1, 3): + new_child = copy.deepcopy(first_child) + new_child_components = copy.deepcopy(first_child_components) + + mock_device_id = f"mock_child_device_id_{i}" + + transport.child_protocols[mock_device_id] = first_child_fake_transport + new_child["device_id"] = mock_device_id + new_child_components["device_id"] = mock_device_id + + added_ids.append(mock_device_id) + mock_child_device_info["child_device_list"].append(new_child) + mock_child_device_components["child_component_list"].append( + new_child_components + ) + + def mock_get_child_device_queries(method, params): + if method in {"getChildDeviceList", "get_child_device_list"}: + result = mock_child_device_info + if method in {"getChildDeviceComponentList", "get_child_device_component_list"}: + result = mock_child_device_components + return {"result": result, "error_code": 0} + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + for added_id in added_ids: + assert added_id in dev._children + expected_new_length = start_child_count + len(added_ids) + assert len(dev.children) == expected_new_length + + # Test removing devices + mock_child_device_info["child_device_list"] = [ + info + for info in mock_child_device_info["child_device_list"] + if info["device_id"] != first_child_device_id + ] + mock_child_device_components["child_component_list"] = [ + cc + for cc in mock_child_device_components["child_component_list"] + if cc["device_id"] != first_child_device_id + ] + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + expected_new_length -= 1 + assert len(dev.children) == expected_new_length + + # Test no child devices + + mock_child_device_info["child_device_list"] = [] + mock_child_device_components["child_component_list"] = [] + mock_child_device_info["sum"] = 0 + mock_child_device_components["sum"] = 0 + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert len(dev.children) == 0 + + # Logging tests are only for smartcam hubs as smart hubs do not test categories + if not isinstance(dev, SmartCamDevice): + return + + # setup + mock_child = copy.deepcopy(first_child) + mock_components = copy.deepcopy(first_child_components) + + mock_child_device_info["child_device_list"] = [mock_child] + mock_child_device_components["child_component_list"] = [mock_components] + mock_child_device_info["sum"] = 1 + mock_child_device_components["sum"] = 1 + + # Test can't find matching components + + mock_child["device_id"] = "no_comps_1" + mock_components["device_id"] = "no_comps_2" + + caplog.set_level("DEBUG") + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child components for device" in caplog.text + + caplog.clear() + + # Test doesn't log multiple + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child components for device" not in caplog.text + + # Test invalid category + + mock_child["device_id"] = "invalid_cat" + mock_components["device_id"] = "invalid_cat" + mock_child["category"] = "foobar" + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" in caplog.text + + caplog.clear() + + # Test doesn't log multiple + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" not in caplog.text + + # Test no category + + mock_child["device_id"] = "no_cat" + mock_components["device_id"] = "no_cat" + mock_child.pop("category") + + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" in caplog.text + + # Test only log once + + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Child device type not supported" not in caplog.text + + # Test no device_id + + mock_child.pop("device_id") + + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child id for device" in caplog.text + + # Test only log once + + caplog.clear() + with patch.object( + transport, "get_child_device_queries", side_effect=mock_get_child_device_queries + ): + await dev.update() + + assert "Could not find child id for device" not in caplog.text + + +@hubs_smart +async def test_unpair(dev: SmartDevice, mocker: MockerFixture) -> None: + """Verify that unpair calls childsetup module.""" + if not dev.children: + pytest.skip("device has no children") + + child = dev.children[0] + + assert child.parent is not None + assert Module.ChildSetup in dev.modules + cs = dev.modules[Module.ChildSetup] + + unpair_call = mocker.spy(cs, "unpair") + + unpair_feat = child.features.get("unpair") + assert unpair_feat + await unpair_feat.set_value(None) + + unpair_call.assert_called_with(child.device_id) diff --git a/tests/smartcam/modules/test_alarm.py b/tests/smartcam/modules/test_alarm.py index 50e0b5b3a..48b249c25 100644 --- a/tests/smartcam/modules/test_alarm.py +++ b/tests/smartcam/modules/test_alarm.py @@ -4,22 +4,21 @@ import pytest -from kasa import Device +from kasa import Device, Module from kasa.smartcam.modules.alarm import ( DURATION_MAX, DURATION_MIN, VOLUME_MAX, VOLUME_MIN, ) -from kasa.smartcam.smartcammodule import SmartCamModule from ...conftest import hub_smartcam @hub_smartcam -async def test_alarm(dev: Device): +async def test_alarm(dev: Device) -> None: """Test device alarm.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm original_duration = alarm.alarm_duration @@ -63,6 +62,19 @@ async def test_alarm(dev: Device): await dev.update() assert alarm.alarm_sound == new_sound + # Test play parameters + await alarm.play( + duration=original_duration, volume=original_volume, sound=original_sound + ) + await dev.update() + assert alarm.active + assert alarm.alarm_sound == original_sound + assert alarm.alarm_duration == original_duration + assert alarm.alarm_volume == original_volume + await alarm.stop() + await dev.update() + assert not alarm.active + finally: await alarm.set_alarm_volume(original_volume) await alarm.set_alarm_duration(original_duration) @@ -71,9 +83,9 @@ async def test_alarm(dev: Device): @hub_smartcam -async def test_alarm_invalid_setters(dev: Device): +async def test_alarm_invalid_setters(dev: Device) -> None: """Test device alarm invalid setter values.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm # test set sound invalid @@ -93,9 +105,9 @@ async def test_alarm_invalid_setters(dev: Device): @hub_smartcam -async def test_alarm_features(dev: Device): +async def test_alarm_features(dev: Device) -> None: """Test device alarm features.""" - alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + alarm = dev.modules.get(Module.Alarm) assert alarm original_duration = alarm.alarm_duration diff --git a/tests/smartcam/modules/test_battery.py b/tests/smartcam/modules/test_battery.py new file mode 100644 index 000000000..9891298ad --- /dev/null +++ b/tests/smartcam/modules/test_battery.py @@ -0,0 +1,96 @@ +"""Tests for smartcam battery module.""" + +from __future__ import annotations + +import pytest + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +battery_smartcam = parametrize( + "has battery", + component_filter="battery", + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, +) + + +@battery_smartcam +async def test_battery(dev: Device) -> None: + """Test device battery.""" + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + required = {"battery_level", "battery_low", "battery_charging"} + optional = {"battery_temperature", "battery_voltage"} + + for feat_id in required: + feat = dev.features.get(feat_id) + assert feat + assert feat.value is not None + + for feat_id in optional: + feat = dev.features.get(feat_id) + if feat is not None: + assert feat.value is not None + + +@battery_smartcam +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, None), # covers: v in (None, "NO") -> return None + ("NO", None), # covers: v in (None, "NO") -> return None + ("nonsense", None), # covers: ValueError -> except -> return None + ("12.3", 12.3), # sanity: happy path + ], +) +async def test_battery_temperature_edge_cases( + dev: Device, raw: str | None, expected: float | None +) -> None: + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + dev.sys_info["battery_temperature"] = raw + assert battery.battery_temperature == expected + + +@battery_smartcam +@pytest.mark.parametrize( + ("voltage_raw", "expected_v"), + [ + (None, None), # covers: battery_voltage -> return None + ("NO", None), # covers: battery_voltage -> return None + ("12000", 12.0), # sanity: parses string -> float(...) / 1000 + ], +) +async def test_battery_voltage_edge_cases( + dev: Device, voltage_raw: str | None, expected_v: float | None +) -> None: + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + dev.sys_info["battery_voltage"] = voltage_raw + assert battery.battery_voltage == expected_v + + +@battery_smartcam +@pytest.mark.parametrize( + ("charging_raw", "expected"), + [ + (True, True), # covers: isinstance(v, bool) -> return v + (False, False), # covers: isinstance(v, bool) -> return v + (None, False), # covers: v is None -> return False + ("yes", True), # sanity: string normalization path + ("NO", False), # sanity: string normalization path + ], +) +async def test_battery_charging_edge_cases( + dev: Device, charging_raw: bool | str | None, expected: bool +) -> None: + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + dev.sys_info["battery_charging"] = charging_raw + assert battery.battery_charging is expected diff --git a/tests/smartcam/test_smartcamera.py b/tests/smartcam/modules/test_camera.py similarity index 55% rename from tests/smartcam/test_smartcamera.py rename to tests/smartcam/modules/test_camera.py index ccb4fbc1a..d8762ef7e 100644 --- a/tests/smartcam/test_smartcamera.py +++ b/tests/smartcam/modules/test_camera.py @@ -4,19 +4,23 @@ import base64 import json -from datetime import UTC, datetime from unittest.mock import patch import pytest -from freezegun.api import FrozenDateTimeFactory -from kasa import Credentials, Device, DeviceType, Module +from kasa import Credentials, Device, DeviceType, Module, StreamResolution -from ..conftest import camera_smartcam, device_smartcam, hub_smartcam +from ...conftest import device_smartcam, parametrize + +not_child_camera_smartcam = parametrize( + "not child camera smartcam", + device_type_filter=[DeviceType.Camera], + protocol_filter={"SMARTCAM"}, +) @device_smartcam -async def test_state(dev: Device): +async def test_state(dev: Device) -> None: if dev.device_type is DeviceType.Hub: pytest.skip("Hubs cannot be switched on and off") @@ -26,8 +30,8 @@ async def test_state(dev: Device): assert dev.is_on is not state -@camera_smartcam -async def test_stream_rtsp_url(dev: Device): +@not_child_camera_smartcam +async def test_stream_rtsp_url(dev: Device) -> None: camera_module = dev.modules.get(Module.Camera) assert camera_module @@ -37,6 +41,16 @@ async def test_stream_rtsp_url(dev: Device): url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" + url = camera_module.stream_rtsp_url( + Credentials("foo", "bar"), stream_resolution=StreamResolution.HD + ) + assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" + + url = camera_module.stream_rtsp_url( + Credentials("foo", "bar"), stream_resolution=StreamResolution.SD + ) + assert url == "rtsp://foo:bar@127.0.0.123:554/stream2" + with patch.object(dev.config, "credentials", Credentials("bar", "foo")): url = camera_module.stream_rtsp_url() assert url == "rtsp://bar:foo@127.0.0.123:554/stream1" @@ -75,49 +89,12 @@ async def test_stream_rtsp_url(dev: Device): url = camera_module.stream_rtsp_url() assert url is None - # Test with camera off - await camera_module.set_state(False) - await dev.update() - url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) - assert url is None - with patch.object(dev.config, "credentials", Credentials("bar", "foo")): - url = camera_module.stream_rtsp_url() - assert url is None - - -@device_smartcam -async def test_alias(dev): - test_alias = "TEST1234" - original = dev.alias - - assert isinstance(original, str) - await dev.set_alias(test_alias) - await dev.update() - assert dev.alias == test_alias - - await dev.set_alias(original) - await dev.update() - assert dev.alias == original - - -@hub_smartcam -async def test_hub(dev): - assert dev.children - for child in dev.children: - assert "Cloud" in child.modules - assert child.modules["Cloud"].data - assert child.alias - await child.update() - assert "Time" not in child.modules - assert child.time +@not_child_camera_smartcam +async def test_onvif_url(dev: Device) -> None: + """Test the onvif url.""" + camera_module = dev.modules.get(Module.Camera) + assert camera_module -@device_smartcam -async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory): - """Test a child device gets the time from it's parent module.""" - fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) - assert dev.time != fallback_time - module = dev.modules[Module.Time] - await module.set_time(fallback_time) - await dev.update() - assert dev.time == fallback_time + url = camera_module.onvif_url() + assert url == "http://127.0.0.123:2020/onvif/device_service" diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py new file mode 100644 index 000000000..dfef66ff7 --- /dev/null +++ b/tests/smartcam/modules/test_childsetup.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, Module + +from ...device_fixtures import parametrize + +childsetup = parametrize( + "supports pairing", component_filter="childQuickSetup", protocol_filter={"SMARTCAM"} +) + + +@childsetup +async def test_childsetup_features(dev: Device) -> None: + """Test the exposed features.""" + cs = dev.modules[Module.ChildSetup] + + assert "pair" in cs._module_features + pair = cs._module_features["pair"] + assert pair.type == Feature.Type.Action + + +@childsetup +async def test_childsetup_pair( + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test device pairing.""" + caplog.set_level(logging.INFO) + mock_query_helper = mocker.spy(dev, "_query_helper") + mocker.patch("asyncio.sleep") + + cs = dev.modules[Module.ChildSetup] + + await cs.pair() + + mock_query_helper.assert_has_awaits( + [ + mocker.call( + "startScanChildDevice", + params={"childControl": {"category": cs.supported_categories}}, + ), + mocker.call( + "getScanChildDeviceList", + {"childControl": {"category": cs.supported_categories}}, + ), + mocker.call( + "addScanChildDeviceList", + { + "childControl": { + "child_device_list": [ + { + "device_id": mocker.ANY, + "category": mocker.ANY, + "device_model": mocker.ANY, + "name": mocker.ANY, + } + ] + } + }, + ), + ] + ) + assert "Discovery done" in caplog.text + + +@childsetup +async def test_childsetup_unpair( + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Test unpair.""" + mock_query_helper = mocker.spy(dev, "_query_helper") + DUMMY_ID = "dummy_id" + + cs = dev.modules[Module.ChildSetup] + + await cs.unpair(DUMMY_ID) + + mock_query_helper.assert_awaited_with( + "removeChildDeviceList", + params={"childControl": {"child_device_list": [{"device_id": DUMMY_ID}]}}, + ) diff --git a/tests/smartcam/modules/test_detections.py b/tests/smartcam/modules/test_detections.py new file mode 100644 index 000000000..b4a664310 --- /dev/null +++ b/tests/smartcam/modules/test_detections.py @@ -0,0 +1,168 @@ +"""Tests for smartcam detections.""" + +from __future__ import annotations + +from typing import NamedTuple + +import pytest + +from kasa import Device +from kasa.modulemapping import ModuleName +from kasa.smartcam import DetectionModule +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...fixtureinfo import filter_fixtures, idgenerator + + +class Detection(NamedTuple): + desc: str + module: ModuleName[DetectionModule] + feature_name: str + component_filter: str + model_filter: str | None = None + + +def parametrize_detection( + *, + model_filter=None, + protocol_filter: set[str] | None = None, + fixture_name: str = "dev", + extra_params_names: list[str], + extra_params_values: list[Detection], +): + _pytest_parameters = [] + + _arg_names = fixture_name + if extra_params_names: + _arg_names = f"{fixture_name},{','.join(extra_params_names)}" + + _model_filter = model_filter + + for _detection in extra_params_values: + if _detection.model_filter: + _model_filter = _detection.model_filter + + extra_values = list(map(lambda x: _detection._asdict()[x], extra_params_names)) + _pytest_parameters.extend( + [ + (i, *extra_values) + for i in filter_fixtures( + _detection.desc, + model_filter=_model_filter, + protocol_filter=protocol_filter, + component_filter=_detection.component_filter, + data_root_filter=None, + device_type_filter=None, + ) + ] + ) + + return pytest.mark.parametrize( + _arg_names, + _pytest_parameters, + indirect=[fixture_name], + ids=idgenerator, + ) + + +detections = [ + Detection( + desc="has baby cry detection", + module=SmartCamModule.SmartCamBabyCryDetection, + feature_name="baby_cry_detection", + component_filter="babyCryDetection", + ), + Detection( + desc="has bark detection", + module=SmartCamModule.SmartCamBarkDetection, + feature_name="bark_detection", + component_filter="barkDetection", + ), + Detection( + desc="has glass detection", + module=SmartCamModule.SmartCamGlassDetection, + feature_name="glass_detection", + component_filter="glassDetection", + ), + Detection( + desc="has line crossing detection", + module=SmartCamModule.SmartCamLineCrossingDetection, + feature_name="line_crossing_detection", + component_filter="linecrossingDetection", + model_filter="C220(EU)_1.0_1.2.5", + ), + Detection( + desc="has meow detection", + module=SmartCamModule.SmartCamMeowDetection, + feature_name="meow_detection", + component_filter="meowDetection", + ), + Detection( + desc="has motion detection", + module=SmartCamModule.SmartCamMotionDetection, + feature_name="motion_detection", + component_filter="detection", + ), + Detection( + desc="has person detection", + module=SmartCamModule.SmartCamPersonDetection, + feature_name="person_detection", + component_filter="personDetection", + ), + Detection( + desc="has pet detection", + module=SmartCamModule.SmartCamPetDetection, + feature_name="pet_detection", + component_filter="petDetection", + ), + Detection( + desc="has tamper detection", + module=SmartCamModule.SmartCamTamperDetection, + feature_name="tamper_detection", + component_filter="tamperDetection", + ), + Detection( + desc="has vehicle detection", + module=SmartCamModule.SmartCamVehicleDetection, + feature_name="vehicle_detection", + component_filter="vehicleDetection", + ), +] + +params_detections = parametrize_detection( + protocol_filter={"SMARTCAM"}, + extra_params_names=["module", "feature_name"], + extra_params_values=detections, +) + + +@params_detections +async def test_detections( + dev: Device, module: ModuleName[DetectionModule], feature_name: str +) -> None: + detection = dev.modules.get(module) + assert detection + + detection_feat = dev.features.get(feature_name) + assert detection_feat + + original_enabled = detection.enabled + + try: + await detection.set_enabled(not original_enabled) + await dev.update() + assert detection.enabled is not original_enabled + assert detection_feat.value is not original_enabled + + await detection.set_enabled(original_enabled) + await dev.update() + assert detection.enabled is original_enabled + assert detection_feat.value is original_enabled + + await detection_feat.set_value(not original_enabled) + await dev.update() + assert detection.enabled is not original_enabled + assert detection_feat.value is not original_enabled + + finally: + await detection.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_pantilt.py b/tests/smartcam/modules/test_pantilt.py new file mode 100644 index 000000000..626ac3337 --- /dev/null +++ b/tests/smartcam/modules/test_pantilt.py @@ -0,0 +1,212 @@ +"""Tests for PanTilt module.""" + +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module + +from ...device_fixtures import parametrize + +pantilt = parametrize( + "has pantilt", component_filter="ptz", protocol_filter={"SMARTCAM"} +) + + +@pantilt +async def test_pantilt_presets(dev: Device, mocker: MockerFixture) -> None: + """Test PanTilt module preset functionality.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + presets = pantilt_mod.presets + if not presets: + pytest.skip("Device has no presets configured") + + assert "ptz_preset" in dev.features + preset_feature = dev.features["ptz_preset"] + assert preset_feature is not None + + first_preset_name = next(iter(presets.keys())) + assert preset_feature.value == first_preset_name + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await preset_feature.set_value(first_preset_name) + + mock_protocol_query.assert_called_once() + call_args = mock_protocol_query.call_args + assert "motorMoveToPreset" in str(call_args) + + +@pantilt +async def test_pantilt_save_preset(dev: Device, mocker: MockerFixture) -> None: + """Test PanTilt save_preset functionality.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.save_preset("NewPreset") + + mock_protocol_query.assert_called_with( + request={ + "addMotorPostion": { + "preset": {"set_preset": {"name": "NewPreset", "save_ptz": "1"}} + } + } + ) + + +@pantilt +async def test_pantilt_invalid_preset(dev: Device, mocker: MockerFixture) -> None: + """Test set_preset with invalid preset name raises ValueError.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + if not pantilt_mod.presets: + pytest.skip("Device has no presets configured") + + preset_feature = dev.features.get("ptz_preset") + if not preset_feature: + pytest.skip("Device has no preset feature") + + mocker.patch.object(dev.protocol, "query", return_value={}) + + with pytest.raises(ValueError, match="Unexpected value"): + await preset_feature.set_value("NonExistentPreset12345") + + +@pantilt +async def test_pantilt_move(dev: Device, mocker: MockerFixture) -> None: + """Test PanTilt move commands.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.pan(30) + call_args = mock_protocol_query.call_args + assert "motor" in str(call_args) + assert "move" in str(call_args) + + mock_protocol_query.reset_mock() + + await pantilt_mod.tilt(10) + call_args = mock_protocol_query.call_args + assert "motor" in str(call_args) + assert "move" in str(call_args) + + +@pantilt +async def test_pantilt_goto_preset(dev: Device, mocker: MockerFixture) -> None: + """Test PanTilt goto_preset command.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.goto_preset("1") + + mock_protocol_query.assert_called_with( + request={"motorMoveToPreset": {"preset": {"goto_preset": {"id": "1"}}}} + ) + + +@pantilt +async def test_pantilt_get_presets(dev: Device, mocker: MockerFixture) -> None: + """Test PanTilt get_presets command.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + await pantilt_mod.get_presets() + + mock_protocol_query.assert_called_with( + request={"getPresetConfig": {"preset": {"name": ["preset"]}}} + ) + + +@pantilt +async def test_pantilt_set_preset_by_id(dev: Device, mocker: MockerFixture) -> None: + """Test set_preset with preset ID instead of name.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + if not pantilt_mod.presets: + pytest.skip("Device has no presets configured") + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + # Get the first preset ID + first_preset_id = next(iter(pantilt_mod.presets.values())) + + # Call set_preset with ID instead of name + await pantilt_mod.set_preset(first_preset_id) + + mock_protocol_query.assert_called_with( + request={ + "motorMoveToPreset": {"preset": {"goto_preset": {"id": first_preset_id}}} + } + ) + + +@pantilt +async def test_pantilt_set_preset_not_found(dev: Device, mocker: MockerFixture) -> None: + """Test set_preset with non-existent preset returns empty dict.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + mock_protocol_query = mocker.patch.object(dev.protocol, "query") + mock_protocol_query.return_value = {} + + # Call set_preset with a non-existent preset + result = await pantilt_mod.set_preset("NonExistentPreset99999") + + # Should return empty dict and not call API + assert result == {} + mock_protocol_query.assert_not_called() + + +@pantilt +async def test_pantilt_step_features(dev: Device, mocker: MockerFixture) -> None: + """Test pan/tilt step features.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + # Test pan_step feature + pan_step_feature = dev.features.get("pan_step") + assert pan_step_feature is not None + assert pan_step_feature.value == 30 # DEFAULT_PAN_STEP + + await pan_step_feature.set_value(45) + assert pantilt_mod._pan_step == 45 + + # Test tilt_step feature + tilt_step_feature = dev.features.get("tilt_step") + assert tilt_step_feature is not None + assert tilt_step_feature.value == 10 # DEFAULT_TILT_STEP + + await tilt_step_feature.set_value(20) + assert pantilt_mod._tilt_step == 20 + + +@pantilt +async def test_pantilt_no_presets_in_data(dev: Device, mocker: MockerFixture) -> None: + """Test _presets returns empty dict when no preset data.""" + pantilt_mod = dev.modules.get(Module.PanTilt) + assert pantilt_mod is not None + + # Mock data property to return empty dict (no preset key) + mocker.patch.object(type(pantilt_mod), "data", property(lambda self: {})) + + assert pantilt_mod._presets == {} + assert pantilt_mod.presets == {} diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py new file mode 100644 index 000000000..d3e38e54d --- /dev/null +++ b/tests/smartcam/test_smartcamdevice.py @@ -0,0 +1,309 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import base64 +import logging +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, PropertyMock, patch +from zoneinfo import ZoneInfo + +import pytest +from freezegun.api import FrozenDateTimeFactory + +from kasa import Device, DeviceType, Module +from kasa.exceptions import AuthenticationError, DeviceError, KasaException +from kasa.smartcam import SmartCamDevice +from kasa.smartcam.modules.time import Time + +from ..conftest import device_smartcam, hub_smartcam + + +@device_smartcam +async def test_state(dev: Device) -> None: + if dev.device_type is DeviceType.Hub: + pytest.skip("Hubs cannot be switched on and off") + + if Module.LensMask in dev.modules: + state = dev.is_on + await dev.set_state(not state) + await dev.update() + assert dev.is_on is not state + + dev.modules.pop(Module.LensMask) # type: ignore[attr-defined] + + # Test with no lens mask module. Device is always on. + assert dev.is_on is True + res = await dev.set_state(False) + assert res == {} + await dev.update() + assert dev.is_on is True + + +@device_smartcam +async def test_alias(dev: Device) -> None: + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + await dev.set_alias(original) + await dev.update() + assert dev.alias == original + + +@hub_smartcam +async def test_hub(dev: Device) -> None: + assert dev.children + for child in dev.children: + assert child.modules + assert child.device_info + + assert child.alias + await child.update() + assert child.device_id + + +@device_smartcam +async def test_wifi_scan(dev: SmartCamDevice) -> None: + fake_scan_data = { + "scanApList": { + "onboarding": { + "scan": { + "publicKey": base64.b64encode(b"fakekey").decode(), + "ap_list": [ + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + } + ], + } + } + } + } + with patch.object(dev, "_query_helper", AsyncMock(return_value=fake_scan_data)): + networks = await dev.wifi_scan() + assert len(networks) == 1 + net = networks[0] + assert net.ssid == "TestSSID" + assert net.auth == "WPA2" + assert net.encryption == "AES" + assert net.rssi == -40 + assert net.bssid == "00:11:22:33:44:55" + assert dev._public_key == base64.b64encode(b"fakekey").decode() + + +@device_smartcam +async def test_wifi_join_success_and_errors(dev: SmartCamDevice) -> None: + dev._networks = [ + type( + "WifiNetwork", + (), + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + }, + )() + ] + with patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock: + cred_mock.return_value = object() + with patch.object(dev.protocol, "query", AsyncMock(return_value={})): + result = await dev.wifi_join("TestSSID", "password123") + assert isinstance(result, dict) + cred_mock.return_value = None + with pytest.raises(AuthenticationError): + await dev.wifi_join("TestSSID", "password123") + cred_mock.return_value = object() + dev._networks = [] + with ( + patch.object(dev, "wifi_scan", AsyncMock(return_value=[])), + pytest.raises(DeviceError), + ): + await dev.wifi_join("TestSSID", "password123") + dev._networks = [ + type( + "WifiNetwork", + (), + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + }, + )() + ] + with ( + patch.object( + dev.protocol, "query", AsyncMock(side_effect=DeviceError("fail")) + ), + pytest.raises(DeviceError), + ): + await dev.wifi_join("TestSSID", "password123") + with patch.object( + dev.protocol, "query", AsyncMock(side_effect=KasaException("fail")) + ): + result = await dev.wifi_join("TestSSID", "password123") + assert result == {} + + +@device_smartcam +async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory) -> None: + """Test a child device gets the time from it's parent module.""" + original_time = dev.time + fallback_time = datetime.now(UTC).replace(tzinfo=ZoneInfo("America/New_York")) + module = dev.modules[Module.Time] + await module.set_time(fallback_time) + await dev.update() + assert dev.timezone == fallback_time.tzinfo + # SmartCam set_time updates timezone only; device clock remains unchanged. + assert dev.time.timestamp() == original_time.timestamp() + + +@device_smartcam +async def test_set_time_updates_timezone_only( + dev: Device, caplog: pytest.LogCaptureFixture +) -> None: + """Test SmartCam set_time updates timezone without changing clock time.""" + original_time = dev.time + set_time_value = datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("Europe/Berlin")) + module = dev.modules[Module.Time] + + with caplog.at_level(logging.WARNING): + await module.set_time(set_time_value) + await dev.update() + + assert ( + "SmartCam devices do not support setting clock time directly; only timezone settings will be updated." + in caplog.text + ) + assert dev.timezone == set_time_value.tzinfo + assert dev.time.timestamp() == original_time.timestamp() + + +@device_smartcam +async def test_set_time_uses_current_timezone_for_naive_datetime(dev: Device) -> None: + """Test SmartCam set_time uses the current timezone for naive datetimes.""" + module = dev.modules[Module.Time] + set_time_value = datetime(2024, 1, 15, 12, 0) + expected_timezone = module.timezone + assert isinstance(expected_timezone, ZoneInfo) + + with patch.object(module, "call", AsyncMock(return_value={})) as call_mock: + await module.set_time(set_time_value) + + call_mock.assert_awaited_once() + call = call_mock.await_args_list[0] + params = call.args[1]["system"]["basic"] + assert params["timezone"] == Time._format_utc_offset( + expected_timezone.utcoffset(set_time_value) + ) + assert params["zone_id"] == expected_timezone.key + + +@pytest.mark.parametrize( + ("set_time_value", "expected_timezone", "expected_zone_id"), + [ + pytest.param( + datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("UTC")), + "UTC+00:00", + "UTC", + id="utc", + ), + pytest.param( + datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("America/New_York")), + "UTC-05:00", + "America/New_York", + id="negative-offset", + ), + pytest.param( + datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("Asia/Kathmandu")), + "UTC+05:45", + "Asia/Kathmandu", + id="partial-hour-offset", + ), + ], +) +@device_smartcam +async def test_set_time_formats_timezone_params( + dev: Device, + set_time_value: datetime, + expected_timezone: str, + expected_zone_id: str, +) -> None: + """Test SmartCam set_time sends both offset and zone id.""" + module = dev.modules[Module.Time] + with patch.object(module, "call", AsyncMock(return_value={})) as call_mock: + await module.set_time(set_time_value) + + call_mock.assert_awaited_once() + call = call_mock.await_args_list[0] + assert call.args[0] == "setTimezone" + params = call.args[1]["system"]["basic"] + assert params["timezone"] == expected_timezone + assert params["zone_id"] == expected_zone_id + + +@device_smartcam +async def test_set_time_rejects_fixed_offset_timezone(dev: Device) -> None: + """Test SmartCam set_time rejects offsets that cannot update zone_id.""" + module = dev.modules[Module.Time] + + with pytest.raises(KasaException, match="zoneinfo timezones"): + await module.set_time(datetime(2024, 1, 15, 12, 0, tzinfo=UTC)) + + +@pytest.mark.parametrize( + ("offset", "expected"), + [ + pytest.param(timedelta(0), "UTC+00:00", id="utc"), + pytest.param(timedelta(hours=-5), "UTC-05:00", id="negative"), + pytest.param(timedelta(hours=5, minutes=45), "UTC+05:45", id="partial-hour"), + pytest.param(None, "UTC+00:00", id="none"), + ], +) +def test_format_utc_offset(offset: timedelta | None, expected: str) -> None: + """Test SmartCam UTC offset formatting.""" + assert Time._format_utc_offset(offset) == expected + + +@device_smartcam +async def test_wifi_join_typeerror_on_non_rsa_key(dev: SmartCamDevice) -> None: + dev._networks = [ + type( + "WifiNetwork", + (), + { + "ssid": "TestSSID", + "auth": "WPA2", + "encryption": "AES", + "rssi": -40, + "bssid": "00:11:22:33:44:55", + }, + )() + ] + with patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock: + cred_mock.return_value = object() + with ( + patch( + "cryptography.hazmat.primitives.serialization.load_der_public_key", + return_value=object(), + ), + patch( + "kasa.smartcam.smartcamdevice.RSAPublicKey", + new=type("FakeRSA", (), {}), + ), + pytest.raises( + TypeError, match="Loaded public key is not an RSA public key" + ), + ): + await dev.wifi_join("TestSSID", "password123") diff --git a/tests/test_bulb.py b/tests/test_bulb.py index 3ae1328f6..14a2ca35d 100644 --- a/tests/test_bulb.py +++ b/tests/test_bulb.py @@ -1,44 +1,20 @@ from __future__ import annotations import re +from collections.abc import Callable +from contextlib import nullcontext import pytest -from voluptuous import ( - All, - Boolean, - Optional, - Range, - Schema, -) - -from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module -from kasa.iot import IotBulb, IotDimmer -from kasa.iot.modules import LightPreset as IotLightPresetModule -from .conftest import ( +from kasa import Device, DeviceType, KasaException, Module +from tests.conftest import handle_turn_on, turn_on +from tests.device_fixtures import ( bulb, - bulb_iot, color_bulb, - color_bulb_iot, - dimmable_iot, - handle_turn_on, non_color_bulb, - non_dimmable_iot, non_variable_temp, - turn_on, variable_temp, - variable_temp_iot, - variable_temp_smart, ) -from .test_iotdevice import SYSINFO_SCHEMA - - -@bulb_iot -async def test_bulb_sysinfo(dev: Device): - assert dev.sys_info is not None - SYSINFO_SCHEMA_BULB(dev.sys_info) - - assert dev.model is not None @bulb @@ -47,25 +23,13 @@ async def test_state_attributes(dev: Device): assert isinstance(dev.state_information["Cloud connection"], bool) -@bulb_iot -async def test_light_state_without_update(dev: IotBulb, monkeypatch): - monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None) - with pytest.raises(KasaException): - print(dev.light_state) - - -@bulb_iot -async def test_get_light_state(dev: IotBulb): - LIGHT_STATE_SCHEMA(await dev.get_light_state()) - - @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) - assert light.is_color + assert light.has_feature("hsv") hue, saturation, brightness = light.hsv assert 0 <= hue <= 360 @@ -81,35 +45,6 @@ async def test_hsv(dev: Device, turn_on): assert brightness == 1 -@color_bulb_iot -async def test_set_hsv_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_hsv(10, 10, 100, transition=1000) - - set_light_state.assert_called_with( - {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, - transition=1000, - ) - - -@bulb_iot -async def test_light_set_state(dev: IotBulb, mocker): - """Testing setting LightState on the light module.""" - light = dev.modules.get(Module.Light) - assert light - set_light_state = mocker.spy(dev, "_set_light_state") - state = LightState(light_on=True) - await light.set_state(state) - - set_light_state.assert_called_with({"on_off": 1}, transition=None) - state = LightState(light_on=False) - await light.set_state(state) - - set_light_state.assert_called_with({"on_off": 0}, transition=None) - - @color_bulb @turn_on @pytest.mark.parametrize( @@ -175,7 +110,7 @@ async def test_invalid_hsv( light = dev.modules.get(Module.Light) assert light await handle_turn_on(dev, turn_on) - assert light.is_color + assert light.has_feature("hsv") with pytest.raises(exception_cls, match=error): await light.set_hsv(hue, sat, brightness) @@ -193,7 +128,7 @@ async def test_color_state_information(dev: Device): async def test_hsv_on_non_color(dev: Device): light = dev.modules.get(Module.Light) assert light - assert not light.is_color + assert not light.has_feature("hsv") with pytest.raises(KasaException): await light.set_hsv(0, 0, 0) @@ -221,33 +156,6 @@ async def test_try_set_colortemp(dev: Device, turn_on): assert light.color_temp == 2700 -@variable_temp_iot -async def test_set_color_temp_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_color_temp(2700, transition=100) - - set_light_state.assert_called_with({"color_temp": 2700}, transition=100) - - -@variable_temp_iot -@pytest.mark.xdist_group(name="caplog") -async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - light = dev.modules.get(Module.Light) - assert light - assert light.valid_temperature_range == (2700, 5000) - assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text - - -@variable_temp_smart -async def test_smart_temp_range(dev: Device): - light = dev.modules.get(Module.Light) - assert light - assert light.valid_temperature_range - - @variable_temp async def test_out_of_range_temperature(dev: Device): light = dev.modules.get(Module.Light) @@ -269,238 +177,74 @@ async def test_non_variable_temp(dev: Device): with pytest.raises(KasaException): await light.set_color_temp(2700) - with pytest.raises(KasaException): - print(light.valid_temperature_range) - with pytest.raises(KasaException): print(light.color_temp) -@dimmable_iot -@turn_on -async def test_dimmable_brightness(dev: IotBulb, turn_on): - assert isinstance(dev, IotBulb | IotDimmer) - light = dev.modules.get(Module.Light) - assert light - await handle_turn_on(dev, turn_on) - assert dev._is_dimmable - - await light.set_brightness(50) - await dev.update() - assert light.brightness == 50 - - await light.set_brightness(10) - await dev.update() - assert light.brightness == 10 - - with pytest.raises(TypeError, match="Brightness must be an integer"): - await light.set_brightness("foo") # type: ignore[arg-type] - - -@bulb_iot -async def test_turn_on_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - await dev.turn_on(transition=1000) - - set_light_state.assert_called_with({"on_off": 1}, transition=1000) - - await dev.turn_off(transition=100) - - set_light_state.assert_called_with({"on_off": 0}, transition=100) - - -@bulb_iot -async def test_dimmable_brightness_transition(dev: IotBulb, mocker): - set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state") - light = dev.modules.get(Module.Light) - assert light - await light.set_brightness(10, transition=1000) - - set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000) - - -@dimmable_iot -async def test_invalid_brightness(dev: IotBulb): - assert dev._is_dimmable - light = dev.modules.get(Module.Light) - assert light - with pytest.raises( - ValueError, - match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"), - ): - await light.set_brightness(110) - - with pytest.raises( - ValueError, - match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"), - ): - await light.set_brightness(-100) - - -@non_dimmable_iot -async def test_non_dimmable(dev: IotBulb): - assert not dev._is_dimmable - light = dev.modules.get(Module.Light) - assert light - with pytest.raises(KasaException): - assert light.brightness == 0 - with pytest.raises(KasaException): - await light.set_brightness(100) - - -@bulb_iot -async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: IotBulb, mocker -): - query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - # When turning back without settings, ignore default to restore the state - await dev.turn_on() - args, kwargs = query_helper.call_args_list[0] - assert args[2] == {"on_off": 1, "ignore_default": 0} - - await dev.turn_off() - args, kwargs = query_helper.call_args_list[1] - assert args[2] == {"on_off": 0, "ignore_default": 1} - - -@bulb_iot -async def test_list_presets(dev: IotBulb): - light_preset = dev.modules.get(Module.LightPreset) - assert light_preset - assert isinstance(light_preset, IotLightPresetModule) - presets = light_preset._deprecated_presets - # Light strip devices may list some light effects along with normal presets but these - # are handled by the LightEffect module so exclude preferred states with id - raw_presets = [ - pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate - ] - assert len(presets) == len(raw_presets) - - for preset, raw in zip(presets, raw_presets, strict=False): - assert preset.index == raw["index"] - assert preset.brightness == raw["brightness"] - assert preset.hue == raw["hue"] - assert preset.saturation == raw["saturation"] - assert preset.color_temp == raw["color_temp"] - - -@bulb_iot -async def test_modify_preset(dev: IotBulb, mocker): - """Verify that modifying preset calls the and exceptions are raised properly.""" - if ( - not (light_preset := dev.modules.get(Module.LightPreset)) - or not light_preset._deprecated_presets - ): - pytest.skip("Some strips do not support presets") - - assert isinstance(light_preset, IotLightPresetModule) - data: dict[str, int | None] = { - "index": 0, - "brightness": 10, - "hue": 0, - "saturation": 0, - "color_temp": 0, - } - preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type] - - assert preset.index == 0 - assert preset.brightness == 10 - assert preset.hue == 0 - assert preset.saturation == 0 - assert preset.color_temp == 0 - - await light_preset._deprecated_save_preset(preset) - await dev.update() - assert light_preset._deprecated_presets[0].brightness == 10 - - with pytest.raises(KasaException): - await light_preset._deprecated_save_preset( - IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg] - ) +@bulb +def test_device_type_bulb(dev: Device): + assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} -@bulb_iot @pytest.mark.parametrize( - ("preset", "payload"), + ("attribute", "use_msg", "use_fn"), [ - ( - IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg] - {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, + pytest.param( + "is_color", + 'use has_feature("hsv") instead', + lambda device, mod: mod.has_feature("hsv"), + id="is_color", ), - ( - IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg] - {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, + pytest.param( + "is_dimmable", + 'use has_feature("brightness") instead', + lambda device, mod: mod.has_feature("brightness"), + id="is_dimmable", ), - ], -) -async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): - """Test that modify preset payloads ignore none values.""" - if ( - not (light_preset := dev.modules.get(Module.LightPreset)) - or not light_preset._deprecated_presets - ): - pytest.skip("Some strips do not support presets") - - query_helper = mocker.patch("kasa.iot.IotBulb._query_helper") - await light_preset._deprecated_save_preset(preset) - query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) - - -LIGHT_STATE_SCHEMA = Schema( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "on_off": Boolean, - "saturation": All(int, Range(min=0, max=100)), - "length": Optional(int), - "transition": Optional(int), - "dft_on_state": Optional( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=0, max=9000)), - "hue": All(int, Range(min=0, max=360)), - "mode": str, - "saturation": All(int, Range(min=0, max=100)), - "groups": Optional(list[int]), - } + pytest.param( + "is_variable_color_temp", + 'use has_feature("color_temp") instead', + lambda device, mod: mod.has_feature("color_temp"), + id="is_variable_color_temp", ), - "err_code": int, - } + pytest.param( + "has_effects", + "check `Module.LightEffect in device.modules` instead", + lambda device, mod: Module.LightEffect in device.modules, + id="has_effects", + ), + ], ) +@bulb +async def test_deprecated_light_is_has_attributes( + dev: Device, attribute: str, use_msg: str, use_fn: Callable[[Device, Module], bool] +): + light = dev.modules.get(Module.Light) + assert light -SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( - { - "ctrl_protocols": Optional(dict), - "description": Optional(str), # Seen on LBxxx, similar to dev_name - "dev_state": str, - "disco_ver": str, - "heapsize": int, - "is_color": Boolean, - "is_dimmable": Boolean, - "is_factory": Boolean, - "is_variable_color_temp": Boolean, - "light_state": LIGHT_STATE_SCHEMA, - "preferred_state": [ - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=360)), - "index": int, - "saturation": All(int, Range(min=0, max=100)), - } - ], - } -) + msg = f"{attribute} is deprecated, {use_msg}" + with pytest.deprecated_call(match=(re.escape(msg))): + result = getattr(light, attribute) + + assert result == use_fn(dev, light) @bulb -def test_device_type_bulb(dev: Device): - assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} +async def test_deprecated_light_valid_temperature_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + color_temp = light.has_feature("color_temp") + dep_msg = ( + "valid_temperature_range is deprecated, use " + 'get_feature("color_temp") minimum_value ' + " and maximum_value instead" + ) + exc_context = pytest.raises(KasaException, match="Color temperature not supported") + expected_context = nullcontext() if color_temp else exc_context -@bulb_iot -async def test_turn_on_behaviours(dev: IotBulb): - behavior = await dev.get_turn_on_behavior() - assert behavior + with ( + expected_context, + pytest.deprecated_call(match=(re.escape(dep_msg))), + ): + assert light.valid_temperature_range # type: ignore[attr-defined] diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py index 1e525efb0..8bcc05db4 100644 --- a/tests/test_childdevice.py +++ b/tests/test_childdevice.py @@ -145,7 +145,9 @@ def __init__(self): super().__init__( SmartDevice("127.0.0.1"), {"device_id": "1", "category": "foobar"}, - {"device", 1}, + { + "component_list": [{"id": "device", "ver_code": 1}], + }, ) assert DummyDevice().device_type is DeviceType.Unknown diff --git a/tests/test_cli.py b/tests/test_cli.py index bb707bb6a..54b345159 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,4 @@ import json -import os import re from datetime import datetime from unittest.mock import ANY, PropertyMock, patch @@ -12,6 +11,7 @@ from kasa import ( AuthenticationError, + ColorTempRange, Credentials, Device, DeviceError, @@ -42,16 +42,20 @@ from kasa.cli.time import time from kasa.cli.usage import energy from kasa.cli.wifi import wifi -from kasa.discover import Discover, DiscoveryResult +from kasa.discover import Discover, DiscoveryResult, redact_data from kasa.iot import IotDevice +from kasa.json import dumps as json_dumps from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice from .conftest import ( + device_iot, device_smart, + device_smartcam, get_device_for_fixture_protocol, handle_turn_on, new_discovery, + parametrize_combine, turn_on, ) @@ -60,15 +64,6 @@ pytestmark = [pytest.mark.requires_dummy] -@pytest.fixture -def runner(): - """Runner fixture that unsets the KASA_ environment variables for tests.""" - KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} - runner = CliRunner(env=KASA_VARS) - - return runner - - async def test_help(runner): """Test that all the lazy modules are correctly names.""" res = await runner.invoke(cli, ["--help"]) @@ -120,20 +115,67 @@ async def test_list_devices(discovery_mock, runner): catch_exceptions=False, ) assert res.exit_code == 0 - header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" - row = f"{discovery_mock.ip:<15} {discovery_mock.device_type:<20} {discovery_mock.encrypt_type:<7}" + header = ( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) + row = ( + f"{discovery_mock.ip:<15} {discovery_mock.model:<9} {discovery_mock.device_type:<20} " + f"{discovery_mock.encrypt_type:<7} {discovery_mock.https:<5} " + f"{discovery_mock.login_version or '-':<3}" + ) assert header in res.output assert row in res.output +async def test_discover_raw(discovery_mock, runner, mocker): + """Test the discover raw command.""" + redact_spy = mocker.patch("kasa.cli.discover.redact_data", side_effect=redact_data) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "raw"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + + expected = { + "discovery_response": discovery_mock.discovery_data, + "meta": {"ip": "127.0.0.123", "port": discovery_mock.discovery_port}, + } + assert res.output == json_dumps(expected, indent=True) + "\n" + + redact_spy.assert_not_called() + + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "raw", "--redact"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + + redact_spy.assert_called() + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + pytest.param( + AuthenticationError("Failed to authenticate"), + "Authentication failed", + id="auth", + ), + pytest.param(TimeoutError(), "Timed out", id="timeout"), + pytest.param(Exception("Foobar"), "Error: Foobar", id="other-error"), + ], +) @new_discovery -async def test_list_auth_failed(discovery_mock, mocker, runner): +async def test_list_update_failed(discovery_mock, mocker, runner, exception, expected): """Test that device update is called on main.""" device_class = Discover._get_device_class(discovery_mock.discovery_data) mocker.patch.object( device_class, "update", - side_effect=AuthenticationError("Failed to authenticate"), + side_effect=exception, ) res = await runner.invoke( cli, @@ -141,10 +183,17 @@ async def test_list_auth_failed(discovery_mock, mocker, runner): catch_exceptions=False, ) assert res.exit_code == 0 - header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" - row = f"{discovery_mock.ip:<15} {discovery_mock.device_type:<20} {discovery_mock.encrypt_type:<7} - Authentication failed" - assert header in res.output - assert row in res.output + header = ( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) + row = ( + f"{discovery_mock.ip:<15} {discovery_mock.model:<9} {discovery_mock.device_type:<20} " + f"{discovery_mock.encrypt_type:<7} {discovery_mock.https:<5} " + f"{discovery_mock.login_version or '-':<3} - {expected}" + ) + assert header in res.output.replace("\n", "") + assert row in res.output.replace("\n", "") async def test_list_unsupported(unsupported_device_info, runner): @@ -155,7 +204,10 @@ async def test_list_unsupported(unsupported_device_info, runner): catch_exceptions=False, ) assert res.exit_code == 0 - header = f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}" + header = ( + f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " + f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + ) row = f"{'127.0.0.1':<15} UNSUPPORTED DEVICE" assert header in res.output assert row in res.output @@ -207,7 +259,8 @@ async def test_alias(dev, runner): res = await runner.invoke(alias, obj=dev) assert f"Alias: {new_alias}" in res.output - await dev.set_alias(old_alias) + # If alias is None set it back to empty string + await dev.set_alias(old_alias or "") async def test_raw_command(dev, mocker, runner): @@ -215,7 +268,11 @@ async def test_raw_command(dev, mocker, runner): from kasa.smart import SmartDevice if isinstance(dev, SmartCamDevice): - params = ["na", "getDeviceInfo"] + params = [ + "na", + "getDeviceInfo", + '{"device_info": {"name": ["basic_info", "info"]}}', + ] elif isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: @@ -303,12 +360,47 @@ async def test_wifi_scan(dev, runner): assert re.search(r"Found [\d]+ wifi networks!", res.output) -@device_smart +@parametrize_combine([device_smart, device_iot]) async def test_wifi_join(dev, mocker, runner): update = mocker.patch.object(dev, "update") res = await runner.invoke( wifi, - ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + ["join", "FOOBAR", "--keytype", "3", "--password", "foobar"], + obj=dev, + ) + + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + + assert res.exit_code == 0 + assert "Asking the device to connect to FOOBAR" in res.output + + +@parametrize_combine([device_smart, device_iot]) +async def test_wifi_join_missing_keytype(dev, mocker, runner): + """Test that missing keytype raises KasaException and CLI echoes the message.""" + update = mocker.patch.object(dev, "update") + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--password", "foobar"], + obj=dev, + ) + + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + + assert res.exit_code == 0 + assert "KeyType is required for this device." in res.output + + +@device_smartcam +async def test_wifi_join_smartcam(dev, mocker, runner): + update = mocker.patch.object(dev, "update") + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--password", "foobar"], obj=dev, ) @@ -389,6 +481,7 @@ async def test_time_sync(dev, mocker, runner): assert "New time: " in res.output +@parametrize_combine([device_smart, device_iot]) async def test_time_set(dev: Device, mocker, runner): """Test time set command.""" time_mod = dev.modules[Module.Time] @@ -492,7 +585,9 @@ async def test_emeter(dev: Device, mocker, runner): async def test_brightness(dev: Device, runner): res = await runner.invoke(brightness, obj=dev) - if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature( + "brightness" + ): assert "This device does not support brightness." in res.output return @@ -509,13 +604,16 @@ async def test_brightness(dev: Device, runner): async def test_color_temperature(dev: Device, runner): res = await runner.invoke(temperature, obj=dev) - if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not ( + color_temp_feat := light.get_feature("color_temp") + ): assert "Device does not support color temperature" in res.output return res = await runner.invoke(temperature, obj=dev) assert f"Color temperature: {light.color_temp}" in res.output - valid_range = light.valid_temperature_range + valid_range = color_temp_feat.range + assert isinstance(valid_range, ColorTempRange) assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output val = int((valid_range.min + valid_range.max) / 2) @@ -541,7 +639,7 @@ async def test_color_temperature(dev: Device, runner): async def test_color_hsv(dev: Device, runner: CliRunner): res = await runner.invoke(hsv, obj=dev) - if not (light := dev.modules.get(Module.Light)) or not light.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"): assert "Device does not support colors" in res.output return @@ -592,8 +690,7 @@ async def test_light_preset(dev: Device, runner: CliRunner): if len(light_preset.preset_states_list) == 0: pytest.skip( - "Some fixtures do not have presets and" - " the api doesn'tsupport creating them" + "Some fixtures do not have presets and the api doesn'tsupport creating them" ) # Start off with a known state first_name = light_preset.preset_list[1] @@ -692,6 +789,8 @@ async def _state(dev: Device): dr.device_type, "--encrypt-type", dr.mgt_encrypt_schm.encrypt_type, + "--login-version", + dr.mgt_encrypt_schm.lv or 1, ], ) assert res.exit_code == 0 @@ -729,6 +828,7 @@ async def test_without_device_type(dev, mocker, runner): timeout=5, discovery_timeout=7, on_unsupported=ANY, + on_discovered_raw=ANY, ) @@ -938,6 +1038,43 @@ async def _state(dev: Device): assert isinstance(result_device, expected_type) +@pytest.mark.parametrize( + ("cli_login_version", "expected_login_version"), + [ + pytest.param(None, 2, id="No login-version defaults to 2"), + pytest.param(3, 3, id="Explicit login-version 3 is preserved"), + pytest.param(2, 2, id="Explicit login-version 2 is preserved"), + ], +) +async def test_type_camera_login_version( + cli_login_version, expected_login_version, mocker, runner +): + """Test that --type camera respects an explicitly provided --login-version.""" + from kasa.deviceconfig import DeviceConfig + + captured_config: DeviceConfig | None = None + + mocker.patch("kasa.cli.device.state") + + async def _mock_connect(config: DeviceConfig): + nonlocal captured_config + captured_config = config + dev = SmartCamDevice(host="127.0.0.1", config=config) + return dev + + mocker.patch("kasa.device.Device.connect", side_effect=_mock_connect) + mocker.patch.object(SmartCamDevice, "update") + + args = ["--type", "camera", "--host", "127.0.0.1"] + if cli_login_version is not None: + args += ["--login-version", str(cli_login_version)] + + res = await runner.invoke(cli, args) + assert res.exit_code == 0, res.output + assert captured_config is not None + assert captured_config.connection_type.login_version == expected_login_version + + @pytest.mark.skip( "Skip until pytest-asyncio supports pytest 8.0, https://github.com/pytest-dev/pytest-asyncio/issues/737" ) @@ -1092,7 +1229,7 @@ async def test_feature_set_child(mocker, runner): mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) get_child_device = mocker.spy(dummy_device, "get_child_device") - child_id = "000000000000000000000000000000000000000001" + child_id = "SCRUBBED_CHILD_DEVICE_ID_1" res = await runner.invoke( cli, @@ -1117,6 +1254,63 @@ async def test_feature_set_child(mocker, runner): assert res.exit_code == 0 +async def test_feature_set_unquoted(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "ES20M(US)_1.0_1.0.11.json", "IOT" + ) + range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_from_str") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "pir_range", "Far"], + catch_exceptions=False, + ) + + range_setter.assert_not_called() + assert "Error: Invalid value: " in res.output + assert res.exit_code != 0 + + +async def test_feature_set_badquoted(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "ES20M(US)_1.0_1.0.11.json", "IOT" + ) + range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_from_str") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "pir_range", "`Far"], + catch_exceptions=False, + ) + + range_setter.assert_not_called() + assert "Error: Invalid value: " in res.output + assert res.exit_code != 0 + + +async def test_feature_set_goodquoted(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "ES20M(US)_1.0_1.0.11.json", "IOT" + ) + range_setter = mocker.patch("kasa.iot.modules.motion.Motion._set_range_from_str") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "pir_range", "'Far'"], + catch_exceptions=False, + ) + + range_setter.assert_called() + assert "Error: Invalid value: " not in res.output + assert res.exit_code == 0 + + async def test_cli_child_commands( dev: Device, runner: CliRunner, mocker: MockerFixture ): @@ -1248,11 +1442,11 @@ async def test_discover_config(dev: Device, mocker, runner): expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" assert expected in res.output assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ failed", + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ failed", res.output.replace("\n", ""), ) assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ succeeded", + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ succeeded", res.output.replace("\n", ""), ) diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py index 32863604f..4eb63ac9a 100644 --- a/tests/test_common_modules.py +++ b/tests/test_common_modules.py @@ -1,14 +1,23 @@ -from datetime import datetime +import importlib +import inspect +import pkgutil +import sys +from datetime import UTC, datetime, timedelta, timezone +from unittest.mock import AsyncMock from zoneinfo import ZoneInfo import pytest from pytest_mock import MockerFixture -from kasa import Device, LightState, Module, ThermostatState +import kasa.interfaces +from kasa import Device, KasaException, LightState, Module, ThermostatState +from kasa.module import _get_feature_attribute from .device_fixtures import ( bulb_iot, bulb_smart, + device_iot, + device_smart, dimmable_iot, dimmer_iot, get_parent_and_child_modules, @@ -56,6 +65,7 @@ light_preset = parametrize_combine([light_preset_smart, bulb_iot]) light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) +time = parametrize_combine([device_smart, device_iot]) temp_control_smart = parametrize( "has temp control smart", @@ -64,6 +74,57 @@ ) +interfaces = pytest.mark.parametrize("interface", kasa.interfaces.__all__) + + +def _get_subclasses(of_class, package): + """Get all the subclasses of a given class.""" + subclasses = set() + # iter_modules returns ModuleInfo: (module_finder, name, ispkg) + for _, modname, ispkg in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package=package.__name__) + module = sys.modules[package.__name__ + "." + modname] + for _, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and obj is not of_class + ): + subclasses.add(obj) + + if ispkg: + res = _get_subclasses(of_class, module) + subclasses.update(res) + + return subclasses + + +@interfaces +def test_feature_attributes(interface): + """Test that all common derived classes define the FeatureAttributes.""" + klass = getattr(kasa.interfaces, interface) + + package = sys.modules["kasa"] + sub_classes = _get_subclasses(klass, package) + + feat_attributes: set[str] = set() + attribute_names = [ + k + for k, v in vars(klass).items() + if (callable(v) and not inspect.isclass(v)) or isinstance(v, property) + ] + for attr_name in attribute_names: + attribute = getattr(klass, attr_name) + if _get_feature_attribute(attribute): + feat_attributes.add(attr_name) + + for sub_class in sub_classes: + for attr_name in feat_attributes: + attribute = getattr(sub_class, attr_name) + fa = _get_feature_attribute(attribute) + assert fa, f"{attr_name} is not a defined module feature for {sub_class}" + + @led async def test_led_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" @@ -176,7 +237,7 @@ async def test_light_brightness(dev: Device): assert light # Test getting the value - feature = light._device.features["brightness"] + feature = light.device.features["brightness"] assert feature.minimum_value == 0 assert feature.maximum_value == 100 @@ -198,14 +259,14 @@ async def test_light_color_temp(dev: Device): light = next(get_parent_and_child_modules(dev, Module.Light)) assert light - if not light.is_variable_color_temp: + if not light.has_feature("color_temp"): pytest.skip( "Some smart light strips have color_temperature" " component but min and max are the same" ) # Test getting the value - feature = light._device.features["color_temperature"] + feature = light.device.features["color_temperature"] assert isinstance(feature.minimum_value, int) assert isinstance(feature.maximum_value, int) @@ -237,7 +298,7 @@ async def test_light_set_state(dev: Device): light = next(get_parent_and_child_modules(dev, Module.Light)) assert light # For fixtures that have a light effect active switch off - if light_effect := light._device.modules.get(Module.LightEffect): + if light_effect := light.device.modules.get(Module.LightEffect): await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) await light.set_state(LightState(light_on=False)) @@ -264,7 +325,7 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): assert preset_mod light_mod = next(get_parent_and_child_modules(dev, Module.Light)) assert light_mod - feat = preset_mod._device.features["light_preset"] + feat = preset_mod.device.features["light_preset"] preset_list = preset_mod.preset_list assert "Not set" in preset_list @@ -364,6 +425,7 @@ async def test_thermostat(dev: Device, mocker: MockerFixture): assert therm_mod.temperature_unit == "fahrenheit" +@time async def test_set_time(dev: Device): """Test setting the device time.""" time_mod = dev.modules[Module.Time] @@ -399,3 +461,214 @@ async def test_set_time(dev: Device): await time_mod.set_time(original_time) await dev.update() assert time_mod.time == original_time + + +async def test_time_post_update_no_time_uses_utc_unit(monkeypatch: pytest.MonkeyPatch): + """If neither get_timezone nor get_time are present, timezone falls back to UTC.""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + monkeypatch.setattr(TimeModule, "data", property(lambda self: {})) + + await TimeModule._post_update_hook(inst) + assert inst.timezone is UTC + + +async def test_time_post_update_uses_offset_when_index_missing_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """When index present but zone not on host, fall back to offset-based guess.""" + from zoneinfo import ZoneInfoNotFoundError + + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + + now = datetime.now(UTC) + data = { + "get_timezone": {"index": 39}, # any index; we'll force failure to load it + "get_time": { + "year": now.year, + "month": now.month, + "mday": now.day, + "hour": now.hour, + "min": now.minute, + "sec": now.second, + }, + } + monkeypatch.setattr(TimeModule, "data", property(lambda self: data)) + + mocker.patch( + "kasa.iot.modules.time.get_timezone", + new=AsyncMock(side_effect=ZoneInfoNotFoundError("missing on host")), + ) + mock_guess = mocker.patch( + "kasa.iot.modules.time._guess_timezone_by_offset", + new=AsyncMock(return_value=timezone(timedelta(0))), + ) + + await TimeModule._post_update_hook(inst) + mock_guess.assert_awaited_once() + # timezone should be set to a valid tzinfo after fallback + assert inst.timezone.utcoffset(now) == timedelta(0) + + +async def test_time_get_time_exception_returns_none_unit(mocker: MockerFixture): + """Cover Time.get_time exception path (unit test of iot Time).""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + mocker.patch.object(inst, "call", new=AsyncMock(side_effect=KasaException("boom"))) + + assert await TimeModule.get_time(inst) is None + + +async def test_time_get_time_success_unit(mocker: MockerFixture): + """Cover the success path of Time.get_time.""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + # Ensure timezone is available on the instance + inst._timezone = UTC + ret = { + "year": 2024, + "month": 1, + "mday": 2, + "hour": 3, + "min": 4, + "sec": 5, + } + mocker.patch.object(inst, "call", new=AsyncMock(return_value=ret)) + + dt = await TimeModule.get_time(inst) + assert dt is not None + assert (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) == ( + 2024, + 1, + 2, + 3, + 4, + 5, + ) + assert dt.tzinfo == inst.timezone + + +async def test_time_post_update_with_time_no_tz_uses_guess_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """When get_time is present but get_timezone is missing, use offset-based guess (dst_expected None).""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + now = datetime.now(UTC) + data = { + "get_time": { + "year": now.year, + "month": now.month, + "mday": now.day, + "hour": now.hour, + "min": now.minute, + "sec": now.second, + } + # Note: no "get_timezone" key + } + monkeypatch.setattr(TimeModule, "data", property(lambda self: data)) + + mock_guess = mocker.patch( + "kasa.iot.modules.time._guess_timezone_by_offset", + new=AsyncMock(return_value=timezone(timedelta(hours=2))), + ) + + await TimeModule._post_update_hook(inst) + mock_guess.assert_awaited_once() + assert inst.timezone.utcoffset(now) == timedelta(hours=2) + + +async def test_time_set_time_wraps_exception_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """Cover exception wrapping in Time.set_time (unit test of iot Time).""" + from kasa.iot.modules.time import Time as TimeModule + + inst = object.__new__(TimeModule) + # Keep data empty so set_time path is chosen (no timezone change) + monkeypatch.setattr(TimeModule, "data", property(lambda self: {})) + mocker.patch.object(inst, "call", new=AsyncMock(side_effect=RuntimeError("err"))) + + with pytest.raises(KasaException): + await TimeModule.set_time(inst, datetime.now()) + + +# New tests to cover remaining smart and smartcam time.py branches + + +async def test_smart_time_set_time_no_region_added_when_tzname_none_unit( + mocker: MockerFixture, +): + """In smart Time.set_time, ensure we cover the branch where tzname() returns None, so 'region' is omitted.""" + from datetime import tzinfo as _tzinfo + + from kasa.smart.modules.time import Time as SmartTimeModule + + class NullNameTZ(_tzinfo): + def utcoffset(self, dt): + return timedelta(hours=1) + + def dst(self, dt): + return timedelta(0) + + def tzname(self, dt): + return None + + inst = object.__new__(SmartTimeModule) + call_mock = mocker.patch.object(inst, "call", new=AsyncMock(return_value={})) + + aware_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=NullNameTZ()) + await SmartTimeModule.set_time(inst, aware_dt) + + call_mock.assert_awaited_once() + args, _ = call_mock.call_args + assert args[0] == "set_device_time" + params = args[1] + # 'region' must not be present when tzname() is None + assert "region" not in params + # sanity: timestamp and time_diff still provided + assert isinstance(params["timestamp"], int) + assert isinstance(params["time_diff"], int) + + +async def test_smartcam_time_post_update_fallback_parses_timezone_str_unit( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +): + """Exercise smartcam Time._post_update_hook fallback when ZoneInfo not found, parsing 'timezone' string.""" + from zoneinfo import ZoneInfoNotFoundError + + from kasa.smartcam.modules.time import Time as CamTimeModule + + inst = object.__new__(CamTimeModule) + # Provide data with an unknown zone_id but with a 'timezone' string like 'UTC+02:00' + ts = 1_700_000_000 + data = { + "getClockStatus": {"system": {"clock_status": {"seconds_from_1970": ts}}}, + "getTimezone": { + "system": {"basic": {"zone_id": "Nowhere/Unknown", "timezone": "UTC+02:00"}} + }, + } + monkeypatch.setattr(CamTimeModule, "data", property(lambda self: data)) + + # Patch directly via the module path instead of sys.modules lookup + mocker.patch( + "kasa.smartcam.modules.time.CachedZoneInfo.get_cached_zone_info", + new=AsyncMock(side_effect=ZoneInfoNotFoundError("missing on host")), + ) + + await CamTimeModule._post_update_hook(inst) + + # Check timezone fallback parsed to +02:00 + now_local = datetime.now(inst.timezone) + assert inst.timezone.utcoffset(now_local) == timedelta(hours=2) + + # Check time set from seconds_from_1970 and is tz-aware with the chosen tz + assert isinstance(inst.time, datetime) + assert inst.time.tzinfo == inst.timezone + assert int(inst.time.timestamp()) == ts diff --git a/tests/test_device.py b/tests/test_device.py index 5cf75a61b..842c42028 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -16,6 +16,7 @@ from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import ( IotBulb, + IotCamera, IotDevice, IotDimmer, IotLightStrip, @@ -30,7 +31,7 @@ ) from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice -from kasa.smartcam import SmartCamDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice def _get_subclasses(of_class): @@ -64,12 +65,13 @@ async def test_alias(dev): test_alias = "TEST1234" original = dev.alias - assert isinstance(original, str) + assert isinstance(original, str | None) await dev.set_alias(test_alias) await dev.update() assert dev.alias == test_alias - await dev.set_alias(original) + # If alias is None set it back to empty string + await dev.set_alias(original or "") await dev.update() assert dev.alias == original @@ -82,10 +84,25 @@ async def test_device_class_ctors(device_class_name_obj): credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) klass = device_class_name_obj[1] - if issubclass(klass, SmartChildDevice): + if issubclass(klass, SmartChildDevice | SmartCamChild): parent = SmartDevice(host, config=config) + smartcam_required = { + "device_model": "foo", + "device_type": "SMART.TAPODOORBELL", + "alias": "Foo", + "sw_ver": "1.1", + "hw_ver": "1.0", + "mac": "1.2.3.4", + "hwId": "hw_id", + "oem_id": "oem_id", + } dev = klass( - parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + parent, + {"dummy": "info", "device_id": "dummy", **smartcam_required}, + { + "component_list": [{"id": "device", "ver_code": 1}], + "app_component_list": [{"name": "device", "version": 1}], + }, ) else: dev = klass(host, config=config) @@ -102,10 +119,15 @@ async def test_device_class_repr(device_class_name_obj): credentials = Credentials("foo", "bar") config = DeviceConfig(host, port_override=port, credentials=credentials) klass = device_class_name_obj[1] - if issubclass(klass, SmartChildDevice): + if issubclass(klass, SmartChildDevice | SmartCamChild): parent = SmartDevice(host, config=config) dev = klass( - parent, {"dummy": "info", "device_id": "dummy"}, {"dummy": "components"} + parent, + {"dummy": "info", "device_id": "dummy"}, + { + "component_list": [{"id": "device", "ver_code": 1}], + "app_component_list": [{"name": "device", "version": 1}], + }, ) else: dev = klass(host, config=config) @@ -118,14 +140,18 @@ async def test_device_class_repr(device_class_name_obj): IotStrip: DeviceType.Strip, IotWallSwitch: DeviceType.WallSwitch, IotLightStrip: DeviceType.LightStrip, + IotCamera: DeviceType.Camera, SmartChildDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown, - SmartCamDevice: DeviceType.Camera, + SmartCamDevice: DeviceType.Unknown, + SmartCamChild: DeviceType.Unknown, } type_ = CLASS_TO_DEFAULT_TYPE[klass] child_repr = ">" not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>" - expected_repr = child_repr if klass is SmartChildDevice else not_child_repr + expected_repr = ( + child_repr if klass in {SmartChildDevice, SmartCamChild} else not_child_repr + ) assert repr(dev) == expected_repr @@ -191,6 +217,13 @@ def test_deprecated_classes(deprecated_class, use_class): "is_strip": DeviceType.Strip, "is_strip_socket": DeviceType.StripSocket, } +deprecated_warns_before_attribute_error = { + "has_effects", + "is_color", + "is_dimmable", + "is_variable_color_temp", + "valid_temperature_range", +} deprecated_is_light_function_smart_module = { "is_color": "Color", "is_dimmable": "Brightness", @@ -218,20 +251,23 @@ def _test_attr(attribute): async def _test_attribute( dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False ): - if is_expected and will_raise: + will_warn = is_expected or attribute_name in deprecated_warns_before_attribute_error + + if will_raise: ctx: AbstractContextManager | nullcontext = pytest.raises(will_raise) - dep_context: pytest.WarningsRecorder | nullcontext = pytest.deprecated_call( - match=(f"{attribute_name} is deprecated, use:") - ) + dep_context: pytest.WarningsRecorder | nullcontext elif is_expected: ctx = nullcontext() - dep_context = pytest.deprecated_call( - match=(f"{attribute_name} is deprecated, use:") - ) else: ctx = pytest.raises( AttributeError, match=f"Device has no attribute '{attribute_name}'" ) + + if will_warn: + dep_context = pytest.deprecated_call( + match=(f"{attribute_name} is deprecated, use:") + ) + else: dep_context = nullcontext() with dep_context, ctx: @@ -270,19 +306,19 @@ async def test_deprecated_light_attributes(dev: Device): await _test_attribute(dev, "is_color", bool(light), "Light") await _test_attribute(dev, "is_variable_color_temp", bool(light), "Light") - exc = KasaException if light and not light.is_dimmable else None + exc = KasaException if light and not light.has_feature("brightness") else None await _test_attribute(dev, "brightness", bool(light), "Light", will_raise=exc) await _test_attribute( dev, "set_brightness", bool(light), "Light", 50, will_raise=exc ) - exc = KasaException if light and not light.is_color else None + exc = KasaException if light and not light.has_feature("hsv") else None await _test_attribute(dev, "hsv", bool(light), "Light", will_raise=exc) await _test_attribute( dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc ) - exc = KasaException if light and not light.is_variable_color_temp else None + exc = KasaException if light and not light.has_feature("color_temp") else None await _test_attribute(dev, "color_temp", bool(light), "Light", will_raise=exc) await _test_attribute( dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 860037445..19ccfb73d 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -13,9 +13,13 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( + BaseProtocol, Credentials, Discover, + IotProtocol, KasaException, + SmartCamProtocol, + SmartProtocol, ) from kasa.device_factory import ( Device, @@ -33,6 +37,16 @@ DeviceFamily, ) from kasa.discover import DiscoveryResult +from kasa.transports import ( + AesTransport, + BaseTransport, + KlapTransport, + KlapTransportV2, + LinkieTransportV2, + SslAesTransport, + SslTransport, + XorTransport, +) from .conftest import DISCOVERY_MOCK_IP @@ -46,9 +60,7 @@ def _get_connection_type_device_class(discovery_info): device_class = Discover._get_device_class(discovery_info) dr = DiscoveryResult.from_dict(discovery_info["result"]) - connection_type = DeviceConnectionParameters.from_values( - dr.device_type, dr.mgt_encrypt_schm.encrypt_type - ) + connection_type = Discover._get_connection_parameters(dr) else: connection_type = DeviceConnectionParameters.from_values( DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value @@ -100,7 +112,7 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port): connection_type=ctype, credentials=Credentials("dummy_user", "dummy_password"), ) - default_port = 80 if "result" in discovery_data else 9999 + default_port = discovery_mock.default_port ctype, _ = _get_connection_type_device_class(discovery_data) @@ -200,3 +212,86 @@ async def test_device_class_from_unknown_family(caplog): with caplog.at_level(logging.DEBUG): assert get_device_class_from_family(dummy_name, https=False) == SmartDevice assert f"Unknown SMART device with {dummy_name}" in caplog.text + + +# Aliases to make the test params more readable +CP = DeviceConnectionParameters +DF = DeviceFamily +ET = DeviceEncryptionType + + +@pytest.mark.parametrize( + ("conn_params", "expected_protocol", "expected_transport"), + [ + pytest.param( + CP(DF.SmartIpCamera, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smartcam", + ), + pytest.param( + CP(DF.SmartTapoHub, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smartcam-hub", + ), + pytest.param( + CP(DF.SmartTapoDoorbell, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smartcam-doorbell", + ), + pytest.param( + CP(DF.IotIpCamera, ET.Aes, https=True), + IotProtocol, + LinkieTransportV2, + id="kasacam", + ), + pytest.param( + CP(DF.SmartTapoRobovac, ET.Aes, https=True), + SmartProtocol, + SslTransport, + id="robovac", + ), + pytest.param( + CP(DF.IotSmartPlugSwitch, ET.Klap, https=False), + IotProtocol, + KlapTransport, + id="iot-klap", + ), + pytest.param( + CP(DF.IotSmartPlugSwitch, ET.Xor, https=False), + IotProtocol, + XorTransport, + id="iot-xor", + ), + pytest.param( + CP(DF.SmartTapoPlug, ET.Aes, https=False), + SmartProtocol, + AesTransport, + id="smart-aes", + ), + pytest.param( + CP(DF.SmartTapoPlug, ET.Klap, https=False), + SmartProtocol, + KlapTransportV2, + id="smart-klap", + ), + pytest.param( + CP(DF.SmartTapoChime, ET.Klap, https=False), + SmartProtocol, + KlapTransportV2, + id="smart-chime", + ), + ], +) +async def test_get_protocol( + conn_params: DeviceConnectionParameters, + expected_protocol: type[BaseProtocol], + expected_transport: type[BaseTransport], +): + """Test get_protocol returns the right protocol.""" + config = DeviceConfig("127.0.0.1", connection_type=conn_params) + protocol = get_protocol(config) + assert isinstance(protocol, expected_protocol) + assert isinstance(protocol._transport, expected_transport) diff --git a/tests/test_devtools.py b/tests/test_devtools.py index e18243afa..b49268d33 100644 --- a/tests/test_devtools.py +++ b/tests/test_devtools.py @@ -1,16 +1,26 @@ """Module for dump_devinfo tests.""" +import copy + import pytest -from devtools.dump_devinfo import get_legacy_fixture, get_smart_fixtures +from devtools.dump_devinfo import ( + _wrap_redactors, + get_legacy_fixture, + get_smart_fixtures, +) from kasa.iot import IotDevice from kasa.protocols import IotProtocol +from kasa.protocols.protocol import redact_data +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT from .conftest import ( FixtureInfo, get_device_for_fixture, + get_fixture_info, parametrize, ) @@ -29,11 +39,13 @@ async def test_fixture_names(fixture_info: FixtureInfo): """Test that device info gets the right fixture names.""" if fixture_info.protocol in {"SMARTCAM"}: device_info = SmartCamDevice._get_device_info( - fixture_info.data, fixture_info.data.get("discovery_result") + fixture_info.data, + fixture_info.data.get("discovery_result", {}).get("result"), ) elif fixture_info.protocol in {"SMART"}: device_info = SmartDevice._get_device_info( - fixture_info.data, fixture_info.data.get("discovery_result") + fixture_info.data, + fixture_info.data.get("discovery_result", {}).get("result"), ) elif fixture_info.protocol in {"SMART.CHILD"}: device_info = SmartDevice._get_device_info(fixture_info.data, None) @@ -62,22 +74,66 @@ async def test_smart_fixtures(fixture_info: FixtureInfo): assert fixture_info.data == fixture_result.data +def _normalize_child_device_ids(info: dict): + """Scrubbed child device ids in hubs may not match ids in child fixtures. + + Different hub fixtures could create the same child fixture so we scrub + them again for the purpose of the test. + """ + if dev_info := info.get("get_device_info"): + dev_info["device_id"] = "SCRUBBED" + elif ( + dev_info := info.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info") + ): + dev_info["dev_id"] = "SCRUBBED" + + @smartcam_fixtures async def test_smartcam_fixtures(fixture_info: FixtureInfo): """Test that smartcam fixtures are created the same.""" dev = await get_device_for_fixture(fixture_info, verbatim=True) assert isinstance(dev, SmartCamDevice) - if dev.children: - pytest.skip("Test not currently implemented for devices with children.") - fixtures = await get_smart_fixtures( + + created_fixtures = await get_smart_fixtures( dev.protocol, discovery_info=fixture_info.data.get("discovery_result"), batch_size=5, ) - fixture_result = fixtures[0] + fixture_result = created_fixtures.pop(0) assert fixture_info.data == fixture_result.data + for created_child_fixture in created_fixtures: + child_fixture_info = get_fixture_info( + created_child_fixture.filename + ".json", + created_child_fixture.protocol_suffix, + ) + + assert child_fixture_info + + _normalize_child_device_ids(created_child_fixture.data) + + saved_fixture_data = copy.deepcopy(child_fixture_info.data) + _normalize_child_device_ids(saved_fixture_data) + saved_fixture_data = { + key: val for key, val in saved_fixture_data.items() if val != -1001 + } + + # Remove the child info from parent from the comparison because the + # child may have been created by a different parent fixture + saved_fixture_data.pop(CHILD_INFO_FROM_PARENT, None) + created_cifp = created_child_fixture.data.pop(CHILD_INFO_FROM_PARENT, None) + + # Still check that the created child info from parent was redacted. + # only smartcam children generate child_info_from_parent + if created_cifp: + redacted_cifp = redact_data(created_cifp, _wrap_redactors(SMART_REDACTORS)) + assert created_cifp == redacted_cifp + + assert saved_fixture_data == created_child_fixture.data + @iot_fixtures async def test_iot_fixtures(fixture_info: FixtureInfo): diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7069e32f6..e2b5042a6 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -134,7 +134,14 @@ async def test_discover_single(discovery_mock, custom_port, mocker): discovery_mock.ip = host discovery_mock.port_override = custom_port - device_class = Discover._get_device_class(discovery_mock.discovery_data) + disco_data = discovery_mock.discovery_data + device_class = Discover._get_device_class(disco_data) + http_port = ( + DiscoveryResult.from_dict(disco_data["result"]).mgt_encrypt_schm.http_port + if "result" in disco_data + else None + ) + # discovery_mock patches protocol query methods so use spy here. update_mock = mocker.spy(device_class, "update") @@ -143,23 +150,27 @@ async def test_discover_single(discovery_mock, custom_port, mocker): ) assert issubclass(x.__class__, Device) assert x._discovery_info is not None - assert x.port == custom_port or x.port == discovery_mock.default_port + assert ( + x.port == custom_port + or x.port == discovery_mock.default_port + or x.port == http_port + ) # Make sure discovery does not call update() assert update_mock.call_count == 0 - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.alias is None ct = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, - discovery_mock.login_version, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, ) - uses_http = discovery_mock.default_port == 80 config = DeviceConfig( host=host, port_override=custom_port, connection_type=ct, - uses_http=uses_http, credentials=Credentials(), ) assert x.config == config @@ -290,12 +301,13 @@ async def mock_discover(self): async def test_discover_send(mocker): """Test discovery parameters.""" discovery_timeout = 0 + discovery_ports = 3 proto = _DiscoverProtocol(discovery_timeout=discovery_timeout) assert proto.discovery_packets == 3 assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") await proto.do_discover() - assert transport.sendto.call_count == proto.discovery_packets * 2 + assert transport.sendto.call_count == proto.discovery_packets * discovery_ports async def test_discover_datagram_received(mocker, discovery_data): @@ -390,13 +402,12 @@ async def test_device_update_from_new_discovery_info(discovery_mock): device_class = Discover._get_device_class(discovery_data) device = device_class("127.0.0.1") discover_info = DiscoveryResult.from_dict(discovery_data["result"]) - discover_dump = discover_info.to_dict() - model, _, _ = discover_dump["device_model"].partition("(") - discover_dump["model"] = model - device.update_from_discover_info(discover_dump) - assert device.mac == discover_dump["mac"].replace("-", ":") - assert device.model == model + device.update_from_discover_info(discovery_data["result"]) + + assert device.mac == discover_info.mac.replace("-", ":") + no_region_model, _, _ = discover_info.device_model.partition("(") + assert device.model == no_region_model # TODO implement requires_update for SmartDevice if isinstance(device, IotDevice): @@ -404,7 +415,7 @@ async def test_device_update_from_new_discovery_info(discovery_mock): KasaException, match=re.escape("You need to await update() to access the data"), ): - assert device.supported_modules + assert device.modules async def test_discover_single_http_client(discovery_mock, mocker): @@ -416,9 +427,9 @@ async def test_discover_single_http_client(discovery_mock, mocker): x: Device = await Discover.discover_single(host) - assert x.config.uses_http == (discovery_mock.default_port == 80) + assert x.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client @@ -433,9 +444,9 @@ async def test_discover_http_client(discovery_mock, mocker): devices = await Discover.discover(discovery_timeout=0) x: Device = devices[host] - assert x.config.uses_http == (discovery_mock.default_port == 80) + assert x.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port == 80: + if discovery_mock.default_port != 9999: assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client @@ -665,8 +676,9 @@ async def test_discover_try_connect_all(discovery_mock, mocker): cparams = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, - discovery_mock.login_version, - discovery_mock.https, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, ) protocol = get_protocol( DeviceConfig(discovery_mock.ip, connection_type=cparams) @@ -678,21 +690,26 @@ async def test_discover_try_connect_all(discovery_mock, mocker): protocol_class = IotProtocol transport_class = XorTransport + default_port = discovery_mock.default_port + async def _query(self, *args, **kwargs): if ( self.__class__ is protocol_class and self._transport.__class__ is transport_class + and self._transport._port == default_port ): return discovery_mock.query_data - raise KasaException() + raise KasaException("Unable to execute query") async def _update(self, *args, **kwargs): if ( self.protocol.__class__ is protocol_class and self.protocol._transport.__class__ is transport_class + and self.protocol._transport._port == default_port ): return - raise KasaException() + + raise KasaException("Unable to execute update") mocker.patch("kasa.IotProtocol.query", new=_query) mocker.patch("kasa.SmartProtocol.query", new=_query) diff --git a/tests/test_feature.py b/tests/test_feature.py index 46cdd116c..bb707688e 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture from kasa import Device, Feature, KasaException +from kasa.iot import IotStrip _LOGGER = logging.getLogger(__name__) @@ -74,7 +75,7 @@ class DummyContainer: def test_prop(self): return "dummy" - dummy_feature.container = DummyContainer() + dummy_feature.container = DummyContainer() # type: ignore[assignment] dummy_feature.attribute_getter = "test_prop" mock_dev_prop = mocker.patch.object( @@ -140,7 +141,10 @@ async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture) mock_setter.assert_called_with("first") mock_setter.reset_mock() - with pytest.raises(ValueError, match="Unexpected value for dummy_feature: invalid"): # noqa: PT012 + with pytest.raises( # noqa: PT012 + ValueError, + match="Unexpected value for dummy_feature: 'invalid' (?: - allowed: .*)?", + ): await dummy_feature.set_value("invalid") assert "Unexpected value" in caplog.text @@ -168,7 +172,10 @@ async def _test_feature(feat, query_mock): if feat.attribute_setter is None: return - expecting_call = feat.id not in internal_setters + # IotStrip makes calls via it's children + expecting_call = feat.id not in internal_setters and not isinstance( + dev, IotStrip + ) if feat.type == Feature.Type.Number: await feat.set_value(feat.minimum_value) @@ -191,7 +198,12 @@ async def _test_features(dev): exceptions = [] for feat in dev.features.values(): try: - with patch.object(feat.device.protocol, "query") as query: + patch_dev = feat.container._device if feat.container else feat.device + with ( + patch.object(patch_dev.protocol, "query", name=feat.id) as query, + # patch update in case feature setter does an update + patch.object(patch_dev, "update"), + ): await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: diff --git a/tests/test_plug.py b/tests/test_plug.py index 795ebe55b..25be910bd 100644 --- a/tests/test_plug.py +++ b/tests/test_plug.py @@ -1,9 +1,9 @@ import pytest from kasa import DeviceType +from tests.iot.test_iotdevice import SYSINFO_SCHEMA from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot -from .test_iotdevice import SYSINFO_SCHEMA # these schemas should go to the mainlib as # they can be useful when adding support for new features/devices diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 394a3aff7..72479c099 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -13,15 +13,21 @@ def test_bulb_examples(mocker): """Use KL130 (bulb with all features) to test the doctests.""" p = asyncio.run(get_device_for_fixture_protocol("KL130(US)_1.0_1.8.11.json", "IOT")) + asyncio.run(p.set_alias("Bedroom Bulb")) + asyncio.run(p.update()) + mocker.patch("kasa.iot.iotbulb.IotBulb", return_value=p) mocker.patch("kasa.iot.iotbulb.IotBulb.update") res = xdoctest.doctest_module("kasa.iot.iotbulb", "all") assert not res["failed"] -def test_smartdevice_examples(mocker): +def test_iotdevice_examples(mocker): """Use HS110 for emeter examples.""" p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) + asyncio.run(p.set_alias("Bedroom Lamp Plug")) + asyncio.run(p.update()) + mocker.patch("kasa.iot.iotdevice.IotDevice", return_value=p) mocker.patch("kasa.iot.iotdevice.IotDevice.update") res = xdoctest.doctest_module("kasa.iot.iotdevice", "all") @@ -31,18 +37,16 @@ def test_smartdevice_examples(mocker): def test_plug_examples(mocker): """Test plug examples.""" p = asyncio.run(get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT")) - # p = await get_device_for_fixture_protocol("HS110(EU)_1.0_1.2.5.json", "IOT") + asyncio.run(p.set_alias("Bedroom Lamp Plug")) + asyncio.run(p.update()) mocker.patch("kasa.iot.iotplug.IotPlug", return_value=p) mocker.patch("kasa.iot.iotplug.IotPlug.update") res = xdoctest.doctest_module("kasa.iot.iotplug", "all") assert not res["failed"] -def test_strip_examples(mocker): +def test_strip_examples(readmes_mock): """Test strip examples.""" - p = asyncio.run(get_device_for_fixture_protocol("KP303(UK)_1.0_1.0.3.json", "IOT")) - mocker.patch("kasa.iot.iotstrip.IotStrip", return_value=p) - mocker.patch("kasa.iot.iotstrip.IotStrip.update") res = xdoctest.doctest_module("kasa.iot.iotstrip", "all") assert not res["failed"] @@ -59,6 +63,8 @@ def test_dimmer_examples(mocker): def test_lightstrip_examples(mocker): """Test lightstrip examples.""" p = asyncio.run(get_device_for_fixture_protocol("KL430(US)_1.0_1.0.10.json", "IOT")) + asyncio.run(p.set_alias("Bedroom Lightstrip")) + asyncio.run(p.update()) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip", return_value=p) mocker.patch("kasa.iot.iotlightstrip.IotLightStrip.update") res = xdoctest.doctest_module("kasa.iot.iotlightstrip", "all") @@ -145,6 +151,25 @@ def test_tutorial_examples(readmes_mock): assert not res["failed"] +def test_childsetup_examples(readmes_mock, mocker): + """Test device examples.""" + pair_resp = [ + { + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ] + mocker.patch( + "kasa.smartcam.modules.childsetup.ChildSetup.pair", return_value=pair_resp + ) + res = xdoctest.doctest_module("kasa.interfaces.childsetup", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + @pytest.fixture async def readmes_mock(mocker): fixture_infos = { @@ -153,5 +178,28 @@ async def readmes_mock(mocker): "127.0.0.3": get_fixture_info("L530E(EU)_3.0_1.1.6.json", "SMART"), # Bulb "127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer + "127.0.0.6": get_fixture_info("H200(US)_1.0_1.3.6.json", "SMARTCAM"), # Hub } + fixture_infos["127.0.0.1"].data["system"]["get_sysinfo"]["alias"] = ( + "Bedroom Power Strip" + ) + for index, child in enumerate( + fixture_infos["127.0.0.1"].data["system"]["get_sysinfo"]["children"] + ): + child["alias"] = f"Plug {index + 1}" + fixture_infos["127.0.0.2"].data["system"]["get_sysinfo"]["alias"] = ( + "Bedroom Lamp Plug" + ) + fixture_infos["127.0.0.3"].data["get_device_info"]["nickname"] = ( + "TGl2aW5nIFJvb20gQnVsYg==" # Living Room Bulb + ) + fixture_infos["127.0.0.4"].data["system"]["get_sysinfo"]["alias"] = ( + "Bedroom Lightstrip" + ) + fixture_infos["127.0.0.5"].data["system"]["get_sysinfo"]["alias"] = ( + "Living Room Dimmer Switch" + ) + fixture_infos["127.0.0.6"].data["getDeviceInfo"]["device_info"]["basic_info"][ + "device_alias" + ] = "Tapo Hub" return patch_discovery(fixture_infos, mocker) diff --git a/tests/test_smartdevice.py b/tests/test_smartdevice.py deleted file mode 100644 index a89b1098d..000000000 --- a/tests/test_smartdevice.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Tests for SMART devices.""" - -from __future__ import annotations - -import logging -import time -from typing import Any, cast -from unittest.mock import patch - -import pytest -from freezegun.api import FrozenDateTimeFactory -from pytest_mock import MockerFixture - -from kasa import Device, KasaException, Module -from kasa.exceptions import DeviceError, SmartErrorCode -from kasa.protocols.smartprotocol import _ChildProtocolWrapper -from kasa.smart import SmartDevice -from kasa.smart.modules.energy import Energy -from kasa.smart.smartmodule import SmartModule - -from .conftest import ( - device_smart, - get_device_for_fixture_protocol, - get_parent_and_child_modules, -) - - -@device_smart -@pytest.mark.requires_dummy -@pytest.mark.xdist_group(name="caplog") -async def test_try_get_response(dev: SmartDevice, caplog): - mock_response: dict = { - "get_device_info": SmartErrorCode.PARAMS_ERROR, - } - caplog.set_level(logging.DEBUG) - dev._try_get_response(mock_response, "get_device_info", {}) - msg = "Error PARAMS_ERROR(-1008) getting request get_device_info for device 127.0.0.123" - assert msg in caplog.text - - -@device_smart -@pytest.mark.requires_dummy -async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture): - mock_response: dict = { - "get_device_usage": {}, - "get_device_time": {}, - } - msg = f"get_device_info not found in {mock_response} for device 127.0.0.123" - mocker.patch.object(dev.protocol, "query", return_value=mock_response) - with pytest.raises(KasaException, match=msg): - await dev.update() - - -@device_smart -async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): - """Test the initial update cycle.""" - # As the fixture data is already initialized, we reset the state for testing - dev._components_raw = None - dev._components = {} - dev._modules = {} - dev._features = {} - dev._children = {} - dev._last_update = {} - dev._last_update_time = None - - negotiate = mocker.spy(dev, "_negotiate") - initialize_modules = mocker.spy(dev, "_initialize_modules") - initialize_features = mocker.spy(dev, "_initialize_features") - - # Perform two updates and verify that initialization is only done once - await dev.update() - await dev.update() - - negotiate.assert_called_once() - assert dev._components_raw is not None - initialize_modules.assert_called_once() - assert dev.modules - initialize_features.assert_called_once() - assert dev.features - - -@device_smart -async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): - """Test that the initial negotiation performs expected steps.""" - # As the fixture data is already initialized, we reset the state for testing - dev._components_raw = None - dev._children = {} - - query = mocker.spy(dev.protocol, "query") - initialize_children = mocker.spy(dev, "_initialize_children") - await dev._negotiate() - - # Check that we got the initial negotiation call - query.assert_any_call( - { - "component_nego": None, - "get_device_info": None, - "get_connect_cloud_state": None, - } - ) - assert dev._components_raw - - # Check the children are created, if device supports them - if "child_device" in dev._components: - initialize_children.assert_called_once() - query.assert_any_call( - { - "get_child_device_component_list": None, - "get_child_device_list": None, - } - ) - assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"] - - -@device_smart -async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): - """Test that the regular update uses queries from all supported modules.""" - # We need to have some modules initialized by now - assert dev._modules - # Reset last update so all modules will query - for mod in dev._modules.values(): - mod._last_update_time = None - - device_queries: dict[SmartDevice, dict[str, Any]] = {} - for mod in dev._modules.values(): - device_queries.setdefault(mod._device, {}).update(mod.query()) - # Hubs do not query child modules by default. - if dev.device_type != Device.Type.Hub: - for child in dev.children: - for mod in child.modules.values(): - device_queries.setdefault(mod._device, {}).update(mod.query()) - - spies = {} - for device in device_queries: - spies[device] = mocker.spy(device.protocol, "query") - - await dev.update() - for device in device_queries: - if device_queries[device]: - # Need assert any here because the child device updates use the parent's protocol - spies[device].assert_any_call(device_queries[device]) - else: - spies[device].assert_not_called() - - -@device_smart -@pytest.mark.xdist_group(name="caplog") -async def test_update_module_update_delays( - dev: SmartDevice, - mocker: MockerFixture, - caplog: pytest.LogCaptureFixture, - freezer: FrozenDateTimeFactory, -): - """Test that modules with minimum delays delay.""" - # We need to have some modules initialized by now - assert dev._modules - - new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) - await new_dev.update() - first_update_time = time.monotonic() - assert new_dev._last_update_time == first_update_time - for module in new_dev.modules.values(): - if module.query(): - assert module._last_update_time == first_update_time - - seconds = 0 - tick = 30 - while seconds <= 180: - seconds += tick - freezer.tick(tick) - - now = time.monotonic() - await new_dev.update() - for module in new_dev.modules.values(): - mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS - if module.query(): - expected_update_time = ( - now if mod_delay == 0 else now - (seconds % mod_delay) - ) - - assert ( - module._last_update_time == expected_update_time - ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" - - -@pytest.mark.parametrize( - ("first_update"), - [ - pytest.param(True, id="First update true"), - pytest.param(False, id="First update false"), - ], -) -@pytest.mark.parametrize( - ("error_type"), - [ - pytest.param(SmartErrorCode.PARAMS_ERROR, id="Device error"), - pytest.param(TimeoutError("Dummy timeout"), id="Query error"), - ], -) -@pytest.mark.parametrize( - ("recover"), - [ - pytest.param(True, id="recover"), - pytest.param(False, id="no recover"), - ], -) -@device_smart -@pytest.mark.xdist_group(name="caplog") -async def test_update_module_query_errors( - dev: SmartDevice, - mocker: MockerFixture, - caplog: pytest.LogCaptureFixture, - freezer: FrozenDateTimeFactory, - first_update, - error_type, - recover, -): - """Test that modules that disabled / removed on query failures. - - i.e. the whole query times out rather than device returns an error. - """ - # We need to have some modules initialized by now - assert dev._modules - - SmartModule.DISABLE_AFTER_ERROR_COUNT = 2 - first_update_queries = {"get_device_info", "get_connect_cloud_state"} - - critical_modules = {Module.DeviceModule, Module.ChildDevice} - - new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) - if not first_update: - await new_dev.update() - freezer.tick( - max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) - ) - - module_queries = { - modname: q - for modname, module in dev._modules.items() - if (q := module.query()) and modname not in critical_modules - } - - async def _query(request, *args, **kwargs): - if ( - "component_nego" in request - or "get_child_device_component_list" in request - or "control_child" in request - ): - resp = await dev.protocol._query(request, *args, **kwargs) - resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR - return resp - # Don't test for errors on get_device_info as that is likely terminal - if len(request) == 1 and "get_device_info" in request: - return await dev.protocol._query(request, *args, **kwargs) - - if isinstance(error_type, SmartErrorCode): - if len(request) == 1: - raise DeviceError("Dummy device error", error_code=error_type) - raise TimeoutError("Dummy timeout") - raise error_type - - child_protocols = { - cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol - for child in dev.children - } - - async def _child_query(self, request, *args, **kwargs): - return await child_protocols[self._device_id]._query(request, *args, **kwargs) - - mocker.patch.object(new_dev.protocol, "query", side_effect=_query) - # children not created yet so cannot patch.object - mocker.patch( - "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", new=_child_query - ) - - await new_dev.update() - - msg = f"Error querying {new_dev.host} for modules" - assert msg in caplog.text - for modname in module_queries: - mod = cast(SmartModule, new_dev.modules[modname]) - assert mod.disabled is False, f"{modname} disabled" - assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS - for mod_query in module_queries[modname]: - if not first_update or mod_query not in first_update_queries: - msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" - assert msg in caplog.text - - # Query again should not run for the modules - caplog.clear() - await new_dev.update() - for modname in module_queries: - mod = cast(SmartModule, new_dev.modules[modname]) - assert mod.disabled is False, f"{modname} disabled" - - freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS) - - caplog.clear() - - if recover: - mocker.patch.object( - new_dev.protocol, "query", side_effect=new_dev.protocol._query - ) - mocker.patch( - "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", - new=_ChildProtocolWrapper._query, - ) - - await new_dev.update() - msg = f"Error querying {new_dev.host} for modules" - if not recover: - assert msg in caplog.text - for modname in module_queries: - mod = cast(SmartModule, new_dev.modules[modname]) - if not recover: - assert mod.disabled is True, f"{modname} not disabled" - assert mod._error_count == 2 - assert mod._last_update_error - for mod_query in module_queries[modname]: - if not first_update or mod_query not in first_update_queries: - msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" - assert msg in caplog.text - # Test one of the raise_if_update_error - if mod.name == "Energy": - emod = cast(Energy, mod) - with pytest.raises(KasaException, match="Module update error"): - assert emod.current_consumption is not None - else: - assert mod.disabled is False - assert mod._error_count == 0 - assert mod._last_update_error is None - # Test one of the raise_if_update_error doesn't raise - if mod.name == "Energy": - emod = cast(Energy, mod) - assert emod.current_consumption is not None - - -async def test_get_modules(): - """Test getting modules for child and parent modules.""" - dummy_device = await get_device_for_fixture_protocol( - "KS240(US)_1.0_1.0.5.json", "SMART" - ) - from kasa.smart.modules import Cloud - - # Modules on device - module = dummy_device.modules.get("Cloud") - assert module - assert module._device == dummy_device - assert isinstance(module, Cloud) - - module = dummy_device.modules.get(Module.Cloud) - assert module - assert module._device == dummy_device - assert isinstance(module, Cloud) - - # Modules on child - module = dummy_device.modules.get("Fan") - assert module is None - module = next(get_parent_and_child_modules(dummy_device, "Fan")) - assert module - assert module._device != dummy_device - assert module._device._parent == dummy_device - - # Invalid modules - module = dummy_device.modules.get("DummyModule") - assert module is None - - module = dummy_device.modules.get(Module.IotAmbientLight) - assert module is None - - -@device_smart -async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): - """Test is_cloud_connected property.""" - assert isinstance(dev, SmartDevice) - assert "cloud_connect" in dev._components - - is_connected = ( - (cc := dev._last_update.get("get_connect_cloud_state")) - and not isinstance(cc, SmartErrorCode) - and cc["status"] == 0 - ) - - assert dev.is_cloud_connected == is_connected - last_update = dev._last_update - - for child in dev.children: - mocker.patch.object(child.protocol, "query", return_value=child._last_update) - - last_update["get_connect_cloud_state"] = {"status": 0} - with patch.object(dev.protocol, "query", return_value=last_update): - await dev.update() - assert dev.is_cloud_connected is True - - last_update["get_connect_cloud_state"] = {"status": 1} - with patch.object(dev.protocol, "query", return_value=last_update): - await dev.update() - assert dev.is_cloud_connected is False - - last_update["get_connect_cloud_state"] = SmartErrorCode.UNKNOWN_METHOD_ERROR - with patch.object(dev.protocol, "query", return_value=last_update): - await dev.update() - assert dev.is_cloud_connected is False - - # Test for no cloud_connect component during device initialisation - component_list = [ - val - for val in dev._components_raw["component_list"] - if val["id"] not in {"cloud_connect"} - ] - initial_response = { - "component_nego": {"component_list": component_list}, - "get_connect_cloud_state": last_update["get_connect_cloud_state"], - "get_device_info": last_update["get_device_info"], - } - - new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) - - first_call = True - - async def side_effect_func(*args, **kwargs): - nonlocal first_call - resp = ( - initial_response - if first_call - else await new_dev.protocol._query(*args, **kwargs) - ) - first_call = False - return resp - - with patch.object( - new_dev.protocol, - "query", - side_effect=side_effect_func, - ): - await new_dev.update() - assert new_dev.is_cloud_connected is False diff --git a/tests/test_sslaestransport.py b/tests/test_sslaestransport.py deleted file mode 100644 index 6816fa35d..000000000 --- a/tests/test_sslaestransport.py +++ /dev/null @@ -1,380 +0,0 @@ -from __future__ import annotations - -import logging -import secrets -from contextlib import nullcontext as does_not_raise -from json import dumps as json_dumps -from json import loads as json_loads -from typing import Any - -import aiohttp -import pytest -from yarl import URL - -from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials -from kasa.deviceconfig import DeviceConfig -from kasa.exceptions import ( - AuthenticationError, - KasaException, - SmartErrorCode, -) -from kasa.httpclient import HttpClient -from kasa.transports.aestransport import AesEncyptionSession -from kasa.transports.sslaestransport import ( - SslAesTransport, - TransportState, - _sha256_hash, -) - -# Transport tests are not designed for real devices -pytestmark = [pytest.mark.requires_dummy] - -MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username -MOCK_PWD = "correct_pwd" # noqa: S105 -MOCK_USER = "mock@example.com" -MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)(" - - -@pytest.mark.parametrize( - ( - "status_code", - "username", - "password", - "wants_default_user", - "digest_password_fail", - "expectation", - ), - [ - pytest.param( - 200, MOCK_USER, MOCK_PWD, False, False, does_not_raise(), id="success" - ), - pytest.param( - 200, - MOCK_USER, - MOCK_PWD, - True, - False, - does_not_raise(), - id="success-default", - ), - pytest.param( - 400, - MOCK_USER, - MOCK_PWD, - False, - False, - pytest.raises(KasaException), - id="400 error", - ), - pytest.param( - 200, - "foobar", - MOCK_PWD, - False, - False, - pytest.raises(AuthenticationError), - id="bad-username", - ), - pytest.param( - 200, - MOCK_USER, - "barfoo", - False, - False, - pytest.raises(AuthenticationError), - id="bad-password", - ), - pytest.param( - 200, - MOCK_USER, - MOCK_PWD, - False, - True, - pytest.raises(AuthenticationError), - id="bad-password-digest", - ), - ], -) -async def test_handshake( - mocker, - status_code, - username, - password, - wants_default_user, - digest_password_fail, - expectation, -): - host = "127.0.0.1" - mock_ssl_aes_device = MockSslAesDevice( - host, - status_code=status_code, - want_default_username=wants_default_user, - digest_password_fail=digest_password_fail, - ) - mocker.patch.object( - aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post - ) - - transport = SslAesTransport( - config=DeviceConfig(host, credentials=Credentials(username, password)) - ) - - assert transport._encryption_session is None - assert transport._state is TransportState.HANDSHAKE_REQUIRED - with expectation: - await transport.perform_handshake() - assert transport._encryption_session is not None - assert transport._state is TransportState.ESTABLISHED - - -@pytest.mark.parametrize( - ("wants_default_user"), - [pytest.param(False, id="username"), pytest.param(True, id="default")], -) -async def test_credentials_hash(mocker, wants_default_user): - host = "127.0.0.1" - mock_ssl_aes_device = MockSslAesDevice( - host, want_default_username=wants_default_user - ) - mocker.patch.object( - aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post - ) - creds = Credentials(MOCK_USER, MOCK_PWD) - creds_hash = SslAesTransport._create_b64_credentials(creds) - - # Test with credentials input - transport = SslAesTransport(config=DeviceConfig(host, credentials=creds)) - assert transport.credentials_hash == creds_hash - await transport.perform_handshake() - assert transport.credentials_hash == creds_hash - - # Test with credentials_hash input - transport = SslAesTransport(config=DeviceConfig(host, credentials_hash=creds_hash)) - mock_ssl_aes_device.handshake1_complete = False - assert transport.credentials_hash == creds_hash - await transport.perform_handshake() - assert transport.credentials_hash == creds_hash - - -async def test_send(mocker): - host = "127.0.0.1" - mock_ssl_aes_device = MockSslAesDevice(host, want_default_username=False) - mocker.patch.object( - aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post - ) - - transport = SslAesTransport( - config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) - ) - request = { - "method": "getDeviceInfo", - "params": None, - } - - res = await transport.send(json_dumps(request)) - assert "result" in res - - -@pytest.mark.xdist_group(name="caplog") -async def test_unencrypted_response(mocker, caplog): - host = "127.0.0.1" - mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True) - mocker.patch.object( - aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post - ) - - transport = SslAesTransport( - config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) - ) - - request = { - "method": "getDeviceInfo", - "params": None, - } - caplog.set_level(logging.DEBUG) - res = await transport.send(json_dumps(request)) - assert "result" in res - assert ( - "Received unencrypted response over secure passthrough from 127.0.0.1" - in caplog.text - ) - - -async def test_port_override(): - """Test that port override sets the app_url.""" - host = "127.0.0.1" - port_override = 12345 - config = DeviceConfig( - host, credentials=Credentials("foo", "bar"), port_override=port_override - ) - transport = SslAesTransport(config=config) - - assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" - - -class MockSslAesDevice: - BAD_USER_RESP = { - "error_code": SmartErrorCode.SESSION_EXPIRED.value, - "result": { - "data": { - "code": -60502, - } - }, - } - - BAD_PWD_RESP = { - "error_code": SmartErrorCode.INVALID_NONCE.value, - "result": { - "data": { - "code": SmartErrorCode.SESSION_EXPIRED.value, - "encrypt_type": ["3"], - "key": "Someb64keyWithUnknownPurpose", - "nonce": "1234567890ABCDEF", # Whatever the original nonce was - "device_confirm": "", - } - }, - } - - class _mock_response: - def __init__(self, status, request: dict): - self.status = status - self._json = request - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_t, exc_v, exc_tb): - pass - - async def read(self): - if isinstance(self._json, dict): - return json_dumps(self._json).encode() - return self._json - - def __init__( - self, - host, - *, - status_code=200, - want_default_username: bool = False, - do_not_encrypt_response=False, - send_response=None, - sequential_request_delay=0, - send_error_code=0, - secure_passthrough_error_code=0, - digest_password_fail=False, - ): - self.host = host - self.http_client = HttpClient(DeviceConfig(self.host)) - self.encryption_session: AesEncyptionSession | None = None - self.server_nonce = secrets.token_bytes(8).hex().upper() - self.handshake1_complete = False - - # test behaviour attributes - self.status_code = status_code - self.send_error_code = send_error_code - self.secure_passthrough_error_code = secure_passthrough_error_code - self.do_not_encrypt_response = do_not_encrypt_response - self.want_default_username = want_default_username - self.digest_password_fail = digest_password_fail - - async def post(self, url: URL, params=None, json=None, data=None, *_, **__): - if data: - json = json_loads(data) - res = await self._post(url, json) - return res - - async def _post(self, url: URL, json: dict[str, Any]): - method = json["method"] - - if method == "login" and not self.handshake1_complete: - return await self._return_handshake1_response(url, json) - - if method == "login" and self.handshake1_complete: - return await self._return_handshake2_response(url, json) - elif method == "securePassthrough": - assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") - return await self._return_secure_passthrough_response(url, json) - else: - assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") - return await self._return_send_response(url, json) - - async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): - request_nonce = request["params"].get("cnonce") - request_username = request["params"].get("username") - - if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( - not self.want_default_username and request_username != MOCK_USER - ): - return self._mock_response(self.status_code, self.BAD_USER_RESP) - - device_confirm = SslAesTransport.generate_confirm_hash( - request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) - ) - self.handshake1_complete = True - resp = { - "error_code": SmartErrorCode.INVALID_NONCE.value, - "result": { - "data": { - "code": SmartErrorCode.INVALID_NONCE.value, - "encrypt_type": ["3"], - "key": "Someb64keyWithUnknownPurpose", - "nonce": self.server_nonce, - "device_confirm": device_confirm, - } - }, - } - return self._mock_response(self.status_code, resp) - - async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): - request_nonce = request["params"].get("cnonce") - request_username = request["params"].get("username") - if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( - not self.want_default_username and request_username != MOCK_USER - ): - return self._mock_response(self.status_code, self.BAD_USER_RESP) - - request_password = request["params"].get("digest_passwd") - expected_pwd = SslAesTransport.generate_digest_password( - request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) - ) - if request_password != expected_pwd or self.digest_password_fail: - return self._mock_response(self.status_code, self.BAD_PWD_RESP) - - lsk = SslAesTransport.generate_encryption_token( - "lsk", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) - ) - ivb = SslAesTransport.generate_encryption_token( - "ivb", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) - ) - self.encryption_session = AesEncyptionSession(lsk, ivb) - resp = { - "error_code": 0, - "result": {"stok": MOCK_STOCK, "user_group": "root", "start_seq": 100}, - } - return self._mock_response(self.status_code, resp) - - async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]): - encrypted_request = json["params"]["request"] - assert self.encryption_session - decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) - decrypted_request_dict = json_loads(decrypted_request) - decrypted_response = await self._post(url, decrypted_request_dict) - async with decrypted_response: - decrypted_response_data = await decrypted_response.read() - - encrypted_response = self.encryption_session.encrypt(decrypted_response_data) - response = ( - decrypted_response_data - if self.do_not_encrypt_response - else encrypted_response - ) - result = { - "result": {"response": response.decode()}, - "error_code": self.secure_passthrough_error_code, - } - return self._mock_response(self.status_code, result) - - async def _return_send_response(self, url: URL, json: dict[str, Any]): - result = {"result": {"method": None}, "error_code": self.send_error_code} - return self._mock_response(self.status_code, result) diff --git a/tests/transports/__init__.py b/tests/transports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_aestransport.py b/tests/transports/test_aestransport.py similarity index 91% rename from tests/test_aestransport.py rename to tests/transports/test_aestransport.py index 64bc8d4e4..7f69a6669 100644 --- a/tests/test_aestransport.py +++ b/tests/transports/test_aestransport.py @@ -6,6 +6,7 @@ import random import string import time +from contextlib import AbstractContextManager from contextlib import nullcontext as does_not_raise from json import dumps as json_dumps from json import loads as json_loads @@ -15,11 +16,13 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from freezegun.api import FrozenDateTimeFactory +from pytest_mock import MockerFixture from yarl import URL from kasa.credentials import Credentials -from kasa.deviceconfig import DeviceConfig +from kasa.deviceconfig import DeviceConfig, KeyPairDict from kasa.exceptions import ( AuthenticationError, KasaException, @@ -42,7 +45,7 @@ KEY_IV = key + iv -def test_encrypt(): +def test_encrypt() -> None: encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) d = json.dumps({"foo": 1, "bar": 2}) @@ -56,7 +59,7 @@ def test_encrypt(): status_parameters = pytest.mark.parametrize( - "status_code, error_code, inner_error_code, expectation", + ("status_code", "error_code", "inner_error_code", "expectation"), [ (200, 0, 0, does_not_raise()), (400, 0, 0, pytest.raises(KasaException)), @@ -68,8 +71,12 @@ def test_encrypt(): @status_parameters async def test_handshake( - mocker, status_code, error_code, inner_error_code, expectation -): + mocker: MockerFixture, + status_code: int, + error_code: int, + inner_error_code: int, + expectation: AbstractContextManager, +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) @@ -86,12 +93,12 @@ async def test_handshake( assert transport._state is TransportState.LOGIN_REQUIRED -async def test_handshake_with_keys(mocker): +async def test_handshake_with_keys(mocker: MockerFixture) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) - test_keys = { + test_keys: KeyPairDict = { "private": "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAMo/JQpXIbP2M3bLOKyfEVCURFCxHIXv4HDME8J58AL4BwGDXf0oQycgj9nV+T/MzgEd/4iVysYuYfLuIEKXADP7Lby6AfA/dbcinZZ7bLUNMNa7TaylIvVKtSfR0LV8AmG0jdQYkr4cTzLAEd+AEs/wG3nMQNEcoQRVY+svLPDjAgMBAAECgYBCsDOch0KbvrEVmMklUoY5Fcq4+M249HIDf6d8VwznTbWxsAmL8nzCKCCG6eF4QiYjhCrAdPQaCS1PF2oXywbLhngid/9W9gz4CKKDJChs1X8KvLi+TLg1jgJUXvq9yVNh1CB+lS2ho4gdDDCbVmiVOZR5TDfEf0xeJ+Zz3zlUEQJBAPkhuNdc3yRue8huFZbrWwikURQPYBxLOYfVTDsfV9mZGSkGoWS1FPDsxrqSXugTmcTRuw+lrXKDabJ72kqywA8CQQDP0oaGh5r7F12Xzcwb7X9JkTvyr+rO8YgVtKNBaNVOPabAzysNwOlvH/sNCVQcRj8rn5LNXitgLx6T+Q5uqa3tAkA7J0elUzbkhps7ju/vYri9x448zh3K+g2R9BJio2GPmCuCM0HVEK4FOqNBH4oLXsQPGKFq6LLTUuKg74l4XRL/AkBHBO6r8pNn0yhMxCtIL/UbsuIFoVBgv/F9WWmg5K5gOnlN0n4oCRC8xPUKE3IG54qW4cVNIS05hWCxuJ7R+nJRAkByt/+kX1nQxis2wIXj90fztXG3oSmoVaieYxaXPxlWvX3/Q5kslFF5UsGy9gcK0v2PXhqjTbhud3/X0Er6YP4v", "public": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDKPyUKVyGz9jN2yzisnxFQlERQsRyF7+BwzBPCefAC+AcBg139KEMnII/Z1fk/zM4BHf+IlcrGLmHy7iBClwAz+y28ugHwP3W3Ip2We2y1DTDWu02spSL1SrUn0dC1fAJhtI3UGJK+HE8ywBHfgBLP8Bt5zEDRHKEEVWPrLyzw4wIDAQAB", } @@ -105,12 +112,19 @@ async def test_handshake_with_keys(mocker): assert transport._state is TransportState.HANDSHAKE_REQUIRED await transport.perform_handshake() + assert transport._key_pair is not None assert transport._key_pair.private_key_der_b64 == test_keys["private"] assert transport._key_pair.public_key_der_b64 == test_keys["public"] @status_parameters -async def test_login(mocker, status_code, error_code, inner_error_code, expectation): +async def test_login( + mocker: MockerFixture, + status_code: int, + error_code: int, + inner_error_code: int, + expectation: AbstractContextManager, +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) @@ -156,7 +170,12 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat "LOGIN_ERROR-SESSION_TIMEOUT_ERROR", ), ) -async def test_login_errors(mocker, inner_error_codes, expectation, call_count): +async def test_login_errors( + mocker: MockerFixture, + inner_error_codes: list[int], + expectation: AbstractContextManager, + call_count: int, +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, 200, 0, inner_error_codes) post_mock = mocker.patch.object( @@ -189,7 +208,13 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): @status_parameters -async def test_send(mocker, status_code, error_code, inner_error_code, expectation): +async def test_send( + mocker: MockerFixture, + status_code: int, + error_code: int, + inner_error_code: int, + expectation: AbstractContextManager, +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, status_code, error_code, inner_error_code) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) @@ -197,7 +222,7 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) ) - transport._handshake_done = True + transport._state = TransportState.ESTABLISHED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session transport._token_url = transport._app_url.with_query( @@ -217,7 +242,9 @@ async def test_send(mocker, status_code, error_code, inner_error_code, expectati @pytest.mark.xdist_group(name="caplog") -async def test_unencrypted_response(mocker, caplog): +async def test_unencrypted_response( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, 200, 0, 0, do_not_encrypt_response=True) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) @@ -248,7 +275,9 @@ async def test_unencrypted_response(mocker, caplog): ) -async def test_unencrypted_response_invalid_json(mocker, caplog): +async def test_unencrypted_response_invalid_json( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice( host, 200, 0, 0, do_not_encrypt_response=True, send_response=b"Foobar" @@ -282,14 +311,16 @@ async def test_unencrypted_response_invalid_json(mocker, caplog): @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) -async def test_passthrough_errors(mocker, error_code): +async def test_passthrough_errors( + mocker: MockerFixture, error_code: SmartErrorCode +) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, 200, error_code, 0) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) config = DeviceConfig(host, credentials=Credentials("foo", "bar")) transport = AesTransport(config=config) - transport._handshake_done = True + transport._state = TransportState.ESTABLISHED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session transport._token_url = transport._app_url.with_query( @@ -308,14 +339,14 @@ async def test_passthrough_errors(mocker, error_code): @pytest.mark.parametrize("error_code", [-13333, 13333]) -async def test_unknown_errors(mocker, error_code): +async def test_unknown_errors(mocker: MockerFixture, error_code: int) -> None: host = "127.0.0.1" mock_aes_device = MockAesDevice(host, 200, error_code, 0) mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) config = DeviceConfig(host, credentials=Credentials("foo", "bar")) transport = AesTransport(config=config) - transport._handshake_done = True + transport._state = TransportState.ESTABLISHED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session transport._token_url = transport._app_url.with_query( @@ -334,7 +365,7 @@ async def test_unknown_errors(mocker, error_code): assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR -async def test_port_override(): +async def test_port_override() -> None: """Test that port override sets the app_url.""" host = "127.0.0.1" config = DeviceConfig( @@ -355,11 +386,11 @@ async def test_port_override(): ], ) async def test_device_closes_connection( - mocker, + mocker: MockerFixture, freezer: FrozenDateTimeFactory, - device_delay_required, - should_error, - should_succeed, + device_delay_required: float, + should_error: bool, + should_succeed: bool, ): """Test the delay logic in http client to deal with devices that close connections after each request. @@ -501,6 +532,7 @@ async def _return_handshake_response(self, url: URL, json: dict[str, Any]): client_pub_key_data = base64.b64decode(client_pub_key.encode()) client_pub_key = serialization.load_der_public_key(client_pub_key_data, None) + assert isinstance(client_pub_key, RSAPublicKey) encrypted_key = client_pub_key.encrypt(KEY_IV, asymmetric_padding.PKCS1v15()) key_64 = base64.b64encode(encrypted_key).decode() return self._mock_response( diff --git a/tests/test_klapprotocol.py b/tests/transports/test_klaptransport.py similarity index 85% rename from tests/test_klapprotocol.py rename to tests/transports/test_klaptransport.py index 26d9f57a4..b1931b99c 100644 --- a/tests/test_klapprotocol.py +++ b/tests/transports/test_klaptransport.py @@ -1,12 +1,17 @@ +from __future__ import annotations + import json import logging import re import secrets import time +from collections.abc import Callable +from contextlib import AbstractContextManager from contextlib import nullcontext as does_not_raise import aiohttp import pytest +from pytest_mock import MockerFixture from yarl import URL from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials @@ -63,8 +68,13 @@ async def read(self): @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_retries_via_client_session( - mocker, retry_count, protocol_class, transport_class, error, retry_expectation -): + mocker: MockerFixture, + retry_count: int, + protocol_class: type, + transport_class: type, + error: Exception, + retry_expectation: bool, +) -> None: host = "127.0.0.1" conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) @@ -91,8 +101,13 @@ async def test_protocol_retries_via_client_session( @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) async def test_protocol_retries_via_httpclient( - mocker, retry_count, protocol_class, transport_class, error, retry_expectation -): + mocker: MockerFixture, + retry_count: int, + protocol_class: type, + transport_class: type, + error: Exception, + retry_expectation: bool, +) -> None: host = "127.0.0.1" conn = mocker.patch.object(HttpClient, "post", side_effect=error) mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) @@ -110,8 +125,8 @@ async def test_protocol_retries_via_httpclient( @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) async def test_protocol_no_retry_on_connection_error( - mocker, protocol_class, transport_class -): + mocker: MockerFixture, protocol_class: type, transport_class: type +) -> None: host = "127.0.0.1" conn = mocker.patch.object( aiohttp.ClientSession, @@ -131,8 +146,8 @@ async def test_protocol_no_retry_on_connection_error( @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) async def test_protocol_retry_recoverable_error( - mocker, protocol_class, transport_class -): + mocker: MockerFixture, protocol_class: type, transport_class: type +) -> None: host = "127.0.0.1" conn = mocker.patch.object( aiohttp.ClientSession, @@ -152,7 +167,9 @@ async def test_protocol_retry_recoverable_error( @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) @pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport_class): +async def test_protocol_reconnect( + mocker: MockerFixture, retry_count: int, protocol_class: type, transport_class: type +) -> None: host = "127.0.0.1" remaining = retry_count mock_response = {"result": {"great": "success"}, "error_code": 0} @@ -185,12 +202,13 @@ def _fail_one_less_than_retry_count(*_, **__): @pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) @pytest.mark.xdist_group(name="caplog") -async def test_protocol_logging(mocker, caplog, log_level): +async def test_protocol_logging( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, log_level: int +) -> None: caplog.set_level(log_level) logging.getLogger("kasa").setLevel(log_level) def _return_encrypted(*_, **__): - nonlocal encryption_session # Do the encrypt just before returning the value so the incrementing sequence number is correct encrypted, seq = encryption_session.encrypt('{"great":"success"}') return 200, encrypted @@ -200,11 +218,12 @@ def _return_encrypted(*_, **__): encryption_session = KlapEncryptionSession(seed, seed, auth_hash) config = DeviceConfig("127.0.0.1") - protocol = IotProtocol(transport=KlapTransport(config=config)) + transport = KlapTransport(config=config) + protocol = IotProtocol(transport=transport) - protocol._transport._handshake_done = True - protocol._transport._session_expire_at = time.time() + 86400 - protocol._transport._encryption_session = encryption_session + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = encryption_session mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted) response = await protocol.query({}) @@ -215,7 +234,7 @@ def _return_encrypted(*_, **__): assert "success" not in caplog.text -def test_encrypt(): +def test_encrypt() -> None: d = json.dumps({"foo": 1, "bar": 2}) seed = secrets.token_bytes(16) @@ -227,7 +246,7 @@ def test_encrypt(): assert d == encryption_session.decrypt(encrypted) -def test_encrypt_unicode(): +def test_encrypt_unicode() -> None: d = "{'snowman': '\u2603'}" seed = secrets.token_bytes(16) @@ -241,7 +260,7 @@ def test_encrypt_unicode(): assert d == decrypted -async def test_transport_decrypt(mocker): +async def test_transport_decrypt(mocker: MockerFixture) -> None: """Test transport decryption.""" d = {"great": "success"} @@ -255,6 +274,7 @@ async def test_transport_decrypt(mocker): transport._encryption_session = encryption_session async def _return_response(url: URL, params=None, data=None, *_, **__): + assert transport._encryption_session is not None encryption_session = KlapEncryptionSession( transport._encryption_session.local_seed, transport._encryption_session.remote_seed, @@ -272,7 +292,9 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): assert d == resp -async def test_transport_decrypt_error(mocker, caplog): +async def test_transport_decrypt_error( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: """Test that a decryption error raises a kasa exception.""" d = {"great": "success"} @@ -286,6 +308,7 @@ async def test_transport_decrypt_error(mocker, caplog): transport._encryption_session = encryption_session async def _return_response(url: URL, params=None, data=None, *_, **__): + assert transport._encryption_session is not None encryption_session = KlapEncryptionSession( secrets.token_bytes(16), transport._encryption_session.remote_seed, @@ -330,10 +353,19 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): ], ) async def test_handshake1( - mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc -): + mocker: MockerFixture, + device_credentials: Credentials, + expectation: AbstractContextManager, + transport_class: type[KlapTransport], + seed_auth_hash_calc: Callable[..., bytes], +) -> None: + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = transport_class.generate_auth_hash(device_credentials) + async def _return_handshake1_response(url, params=None, data=None, *_, **__): - nonlocal client_seed, server_seed, device_auth_hash + nonlocal client_seed client_seed = data seed_auth_hash = _sha256( @@ -341,24 +373,20 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): ) return _mock_response(200, server_seed + seed_auth_hash) - client_seed = None - server_seed = secrets.token_bytes(16) - client_credentials = Credentials("foo", "bar") - device_auth_hash = transport_class.generate_auth_hash(device_credentials) - mocker.patch.object( aiohttp.ClientSession, "post", side_effect=_return_handshake1_response ) config = DeviceConfig("127.0.0.1", credentials=client_credentials) - protocol = IotProtocol(transport=transport_class(config=config)) + transport = transport_class(config=config) + protocol = IotProtocol(transport=transport) with expectation: ( local_seed, device_remote_seed, auth_hash, - ) = await protocol._transport.perform_handshake1() + ) = await transport.perform_handshake1() assert local_seed == client_seed assert device_remote_seed == server_seed @@ -381,15 +409,18 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): ], ) async def test_handshake( - mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 -): + mocker: MockerFixture, + transport_class: type[KlapTransport], + seed_auth_hash_calc1: Callable[..., bytes], + seed_auth_hash_calc2: Callable[..., bytes], +) -> None: client_seed = None server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") device_auth_hash = transport_class.generate_auth_hash(client_credentials) async def _return_handshake_response(url: URL, params=None, data=None, *_, **__): - nonlocal client_seed, server_seed, device_auth_hash + nonlocal client_seed if url == URL("http://127.0.0.1:80/app/handshake1"): client_seed = data @@ -410,21 +441,21 @@ async def _return_handshake_response(url: URL, params=None, data=None, *_, **__) ) config = DeviceConfig("127.0.0.1", credentials=client_credentials) - protocol = IotProtocol(transport=transport_class(config=config)) - protocol._transport.http_client = aiohttp.ClientSession() + transport = transport_class(config=config) + protocol = IotProtocol(transport=transport) response_status = 200 - await protocol._transport.perform_handshake() - assert protocol._transport._handshake_done is True + await transport.perform_handshake() + assert transport._handshake_done is True response_status = 403 with pytest.raises(KasaException): - await protocol._transport.perform_handshake() - assert protocol._transport._handshake_done is False + await transport.perform_handshake() + assert transport._handshake_done is False await protocol.close() -async def test_query(mocker): +async def test_query(mocker: MockerFixture) -> None: client_seed = None last_seq = None seq = None @@ -433,7 +464,7 @@ async def test_query(mocker): device_auth_hash = KlapTransport.generate_auth_hash(client_credentials) async def _return_response(url: URL, params=None, data=None, *_, **__): - nonlocal client_seed, server_seed, device_auth_hash, seq + nonlocal client_seed, seq if url == URL("http://127.0.0.1:80/app/handshake1"): client_seed = data @@ -443,10 +474,11 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): elif url == URL("http://127.0.0.1:80/app/handshake2"): return _mock_response(200, b"") elif url == URL("http://127.0.0.1:80/app/request"): + assert transport._encryption_session is not None encryption_session = KlapEncryptionSession( - protocol._transport._encryption_session.local_seed, - protocol._transport._encryption_session.remote_seed, - protocol._transport._encryption_session.user_hash, + transport._encryption_session.local_seed, + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, ) seq = params.get("seq") encryption_session._seq = seq - 1 @@ -457,7 +489,8 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) config = DeviceConfig("127.0.0.1", credentials=client_credentials) - protocol = IotProtocol(transport=KlapTransport(config=config)) + transport = KlapTransport(config=config) + protocol = IotProtocol(transport=transport) for _ in range(10): resp = await protocol.query({}) @@ -509,8 +542,11 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): ], ) async def test_authentication_failures( - mocker, response_status, credentials_match, expectation -): + mocker: MockerFixture, + response_status: tuple[int, ...], + credentials_match: bool | bytes, + expectation: AbstractContextManager, +) -> None: client_seed = None server_seed = secrets.token_bytes(16) @@ -521,12 +557,7 @@ async def test_authentication_failures( device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) async def _return_response(url: URL, params=None, data=None, *_, **__): - nonlocal \ - client_seed, \ - server_seed, \ - device_auth_hash, \ - response_status, \ - credentials_match + nonlocal client_seed if url == URL("http://127.0.0.1:80/app/handshake1"): client_seed = data @@ -554,7 +585,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): await protocol.query({}) -async def test_port_override(): +async def test_port_override() -> None: """Test that port override sets the app_url.""" host = "127.0.0.1" config = DeviceConfig( diff --git a/tests/transports/test_linkietransport.py b/tests/transports/test_linkietransport.py new file mode 100644 index 000000000..a907513a5 --- /dev/null +++ b/tests/transports/test_linkietransport.py @@ -0,0 +1,147 @@ +import base64 +from unittest.mock import ANY + +import aiohttp +import pytest +from pytest_mock import MockerFixture +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.transports.linkietransport import LinkieTransportV2 + +KASACAM_REQUEST_PLAINTEXT = '{"smartlife.cam.ipcamera.dateTime":{"get_status":{}}}' +KASACAM_RESPONSE_ENCRYPTED = "0PKG74LnnfKc+dvhw5bCgaycqZOjk7Gdv96syaiKsJLTvtupwKPC7aPGse632KrB48/tiPiX9JzDsNW2lK6fqZCgmKuZoZGh3A==" +KASACAM_RESPONSE_ERROR = '{"smartlife.cam.ipcamera.cloud": {"get_inf": {"err_code": -10008, "err_msg": "Unsupported API call."}}}' +KASA_DEFAULT_CREDENTIALS_HASH = "YWRtaW46MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM=" + + +async def test_working(mocker: MockerFixture) -> None: + """No errors with an expected request/response.""" + host = "127.0.0.1" + mock_linkie_device = MockLinkieDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post + ) + transport_no_creds = LinkieTransportV2(config=DeviceConfig(host)) + + response = await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT) + assert response == { + "timezone": "UTC-05:00", + "area": "America/New_York", + "epoch_sec": 1690832800, + } + + +async def test_credentials_hash(mocker: MockerFixture) -> None: + """Ensure the default credentials are always passed as Basic Auth.""" + # Test without credentials input + + host = "127.0.0.1" + mock_linkie_device = MockLinkieDevice(host) + mock_post = mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post + ) + transport_no_creds = LinkieTransportV2(config=DeviceConfig(host)) + await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT) + mock_post.assert_called_once_with( + URL(f"https://{host}:10443/data/LINKIE2.json"), + params=None, + data=ANY, + json=None, + timeout=ANY, + cookies=None, + headers={ + "Authorization": "Basic " + _generate_kascam_basic_auth(), + "Content-Type": "application/x-www-form-urlencoded", + }, + ssl=ANY, + ) + + assert transport_no_creds.credentials_hash == KASA_DEFAULT_CREDENTIALS_HASH + # Test with credentials input + + transport_with_creds = LinkieTransportV2( + config=DeviceConfig(host, credentials=Credentials("Admin", "password")) + ) + mock_post.reset_mock() + + await transport_with_creds.send(KASACAM_REQUEST_PLAINTEXT) + mock_post.assert_called_once_with( + URL(f"https://{host}:10443/data/LINKIE2.json"), + params=None, + data=ANY, + json=None, + timeout=ANY, + cookies=None, + headers={ + "Authorization": "Basic " + _generate_kascam_basic_auth(), + "Content-Type": "application/x-www-form-urlencoded", + }, + ssl=ANY, + ) + + +@pytest.mark.parametrize( + ("return_status", "return_data", "expected"), + [ + (500, KASACAM_RESPONSE_ENCRYPTED, "500"), + (200, "AAAAAAAAAAAAAAAAAAAAAAAA", "Unable to read response"), + (200, KASACAM_RESPONSE_ERROR, "Unsupported API call"), + ], +) +async def test_exceptions( + mocker: MockerFixture, return_status: int, return_data: str, expected: str +) -> None: + """Test a variety of possible responses from the device.""" + host = "127.0.0.1" + transport = LinkieTransportV2(config=DeviceConfig(host)) + mock_linkie_device = MockLinkieDevice( + host, status_code=return_status, response=return_data + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post + ) + + with pytest.raises(KasaException, match=expected): + await transport.send(KASACAM_REQUEST_PLAINTEXT) + + +def _generate_kascam_basic_auth() -> str: + creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"]) + creds_combined = f"{creds.username}:{creds.password}" + return base64.b64encode(creds_combined.encode()).decode() + + +class MockLinkieDevice: + """Based on MockSslDevice.""" + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__(self, host, *, status_code=200, response=KASACAM_RESPONSE_ENCRYPTED): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + self.status_code = status_code + self.response = response + + async def post( + self, url: URL, *, headers=None, params=None, json=None, data=None, **__ + ): + return self._mock_response(self.status_code, self.response) diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py new file mode 100644 index 000000000..fe4081950 --- /dev/null +++ b/tests/transports/test_sslaestransport.py @@ -0,0 +1,741 @@ +from __future__ import annotations + +import base64 +import logging +import secrets +from contextlib import AbstractContextManager +from contextlib import nullcontext as does_not_raise +from json import dumps as json_dumps +from json import loads as json_loads +from typing import Any + +import aiohttp +import pytest +from pytest_mock import MockerFixture +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.transports.aestransport import AesEncyptionSession +from kasa.transports.sslaestransport import ( + SslAesTransport, + TransportState, + _md5_hash, + _sha256_hash, +) + +# Transport tests are not designed for real devices +# SslAesTransport use a socket to get it's own ip address +pytestmark = [pytest.mark.requires_dummy, pytest.mark.enable_socket] + +MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username +MOCK_PWD = "correct_pwd" # noqa: S105 +MOCK_USER = "mock@example.com" +MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)(" +MOCK_UNENCRYPTED_PASSTHROUGH_STOK = "32charLowerCaseHexStok" + + +@pytest.mark.parametrize( + ( + "status_code", + "username", + "password", + "wants_default_user", + "digest_password_fail", + "expectation", + ), + [ + pytest.param( + 200, MOCK_USER, MOCK_PWD, False, False, does_not_raise(), id="success" + ), + pytest.param( + 200, + MOCK_USER, + MOCK_PWD, + True, + False, + does_not_raise(), + id="success-default", + ), + pytest.param( + 400, + MOCK_USER, + MOCK_PWD, + False, + False, + pytest.raises(KasaException), + id="400 error", + ), + pytest.param( + 200, + "foobar", + MOCK_PWD, + False, + False, + pytest.raises(AuthenticationError), + id="bad-username", + ), + pytest.param( + 200, + MOCK_USER, + "barfoo", + False, + False, + pytest.raises(AuthenticationError), + id="bad-password", + ), + pytest.param( + 200, + MOCK_USER, + MOCK_PWD, + False, + True, + pytest.raises(AuthenticationError), + id="bad-password-digest", + ), + ], +) +async def test_handshake( + mocker: MockerFixture, + status_code: int, + username: str, + password: str, + wants_default_user: bool, + digest_password_fail: bool, + expectation: AbstractContextManager, +) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, + status_code=status_code, + want_default_username=wants_default_user, + digest_password_fail=digest_password_fail, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(username, password)) + ) + + assert transport._encryption_session is None + assert transport._state is TransportState.HANDSHAKE_REQUIRED + with expectation: + await transport.perform_handshake() + assert transport._encryption_session is not None + assert transport._state is TransportState.ESTABLISHED + + +@pytest.mark.parametrize( + ("wants_default_user"), + [pytest.param(False, id="username"), pytest.param(True, id="default")], +) +async def test_credentials_hash( + mocker: MockerFixture, wants_default_user: bool +) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, want_default_username=wants_default_user + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + creds = Credentials(MOCK_USER, MOCK_PWD) + creds_hash = SslAesTransport._create_b64_credentials(creds) + + # Test with credentials input + transport = SslAesTransport(config=DeviceConfig(host, credentials=creds)) + assert transport.credentials_hash == creds_hash + await transport.perform_handshake() + assert transport.credentials_hash == creds_hash + + # Test with credentials_hash input + transport = SslAesTransport(config=DeviceConfig(host, credentials_hash=creds_hash)) + mock_ssl_aes_device.handshake1_complete = False + assert transport.credentials_hash == creds_hash + await transport.perform_handshake() + assert transport.credentials_hash == creds_hash + + +async def test_send(mocker: MockerFixture) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, want_default_username=False) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + request = { + "method": "getDeviceInfo", + "params": None, + } + + res = await transport.send(json_dumps(request)) + assert "result" in res + + +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_response( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + caplog.set_level(logging.DEBUG) + res = await transport.send(json_dumps(request)) + assert "result" in res + assert ( + "Received unencrypted response over secure passthrough from 127.0.0.1" + in caplog.text + ) + + +@pytest.mark.parametrize(("want_default"), [True, False]) +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_passthrough( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, want_default: bool +) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, unencrypted_passthrough=True, want_default_username=want_default + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + caplog.set_level(logging.DEBUG) + res = await transport.send(json_dumps(request)) + assert "result" in res + assert ( + f"Succesfully logged in to {host} with less secure passthrough" in caplog.text + ) + + +@pytest.mark.parametrize(("want_default"), [True, False]) +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_passthrough_errors( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture, want_default: bool +) -> None: + host = "127.0.0.1" + request = { + "method": "getDeviceInfo", + "params": None, + } + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + caplog.set_level(logging.DEBUG) + + # Test bad password + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + digest_password_fail=True, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"Unable to log in to {host} with less secure login" + with pytest.raises(AuthenticationError): + await transport.send(json_dumps(request)) + + assert msg in caplog.text + + # Test bad status code in handshake + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + status_code=401, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"{host} responded with an unexpected status code 401 to handshake1" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + # Test bad status code in login + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + status_code_list=[200, 401], + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"{host} responded with an unexpected status code 401 to login" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + # Test bad status code in send + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + status_code_list=[200, 200, 401], + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"{host} responded with an unexpected status code 401 to unencrypted send" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + # Test error code in send response + mock_ssl_aes_device = MockSslAesDevice( + host, + unencrypted_passthrough=True, + want_default_username=want_default, + send_error_code=SmartErrorCode.BAD_USERNAME.value, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + msg = f"Error sending message: {host}:" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + +async def test_device_blocked_response(mocker: MockerFixture) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + msg = "Device blocked for 1685 seconds" + + with pytest.raises(DeviceError, match=msg): + await transport.perform_handshake() + + +@pytest.mark.parametrize( + ("response", "expected_msg"), + [ + pytest.param( + {"error_code": -1, "msg": "Check tapo tag failed"}, + '{"error_code": -1, "msg": "Check tapo tag failed"}', + id="can-decrypt", + ), + pytest.param( + b"12345678", + str({"result": {"response": "12345678"}, "error_code": 0}), + id="cannot-decrypt", + ), + ], +) +async def test_device_500_error( + mocker: MockerFixture, response: dict | bytes, expected_msg: str +) -> None: + """Test 500 error raises retryable exception.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + + await transport.perform_handshake() + + mock_ssl_aes_device.put_next_response(response) + mock_ssl_aes_device.status_code = 500 + + msg = f"Device 127.0.0.1 replied with status 500 after handshake, response: {expected_msg}" + with pytest.raises(_RetryableError, match=msg): + await transport.send(json_dumps(request)) + + +async def test_port_override() -> None: + """Test that port override sets the app_url.""" + host = "127.0.0.1" + port_override = 12345 + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=port_override + ) + transport = SslAesTransport(config=config) + + assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" + + +@pytest.mark.parametrize( + ("login_version", "expected_password_b64"), + [ + pytest.param( + 3, + "VFBMMDc1NTI2NDYwNjAz", # noqa: S105 + id="version-3-uses-lv3-credentials", + ), + pytest.param( + 2, + "YWRtaW4=", # noqa: S105 + id="version-2-uses-tapocamera-credentials", + ), + pytest.param( + None, + "YWRtaW4=", # noqa: S105 + id="no-version-uses-tapocamera-credentials", + ), + ], +) +async def test_login_version_default_credentials( + mocker: MockerFixture, login_version: int | None, expected_password_b64: str +) -> None: + """Test that login_version=3 uses TAPOCAMERA_LV3 credentials while other versions use TAPOCAMERA.""" + host = "127.0.0.1" + tapo_family = DeviceFamily.SmartIpCamera + aes_type = DeviceEncryptionType.Aes + mock_ssl_aes_device = MockSslAesDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + config = DeviceConfig( + host, + credentials=Credentials("foo", "bar"), + connection_type=DeviceConnectionParameters( + tapo_family, aes_type, login_version=login_version + ), + ) + transport = SslAesTransport(config=config) + assert transport._default_credentials.username == "admin" + password_b64 = base64.b64encode( + transport._default_credentials.password.encode() + ).decode() + assert password_b64 == expected_password_b64 + + +class MockSslAesDevice: + BAD_USER_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": -60502, + } + }, + } + + BAD_PWD_RESP = { + "error_code": SmartErrorCode.INVALID_NONCE.value, + "result": { + "data": { + "code": SmartErrorCode.SESSION_EXPIRED.value, + "encrypt_type": ["3"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "1234567890ABCDEF", # Whatever the original nonce was + "device_confirm": "", + } + }, + } + + DEVICE_BLOCKED_RESP = { + "data": {"code": SmartErrorCode.DEVICE_BLOCKED.value, "sec_left": 1685}, + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + } + + UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": SmartErrorCode.BAD_USERNAME.value, + "encrypt_type": ["1", "2"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "MixedCaseAlphaNumericWithUnknownPurpose", + } + }, + } + + UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": SmartErrorCode.SESSION_EXPIRED.value, + "time": 9, + "max_time": 10, + "sec_left": 0, + "encrypt_type": ["1", "2"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "MixedCaseAlphaNumericWithUnknownPurpose", + } + }, + } + + UNENCRYPTED_PASSTHROUGH_GOOD_LOGIN_RESPONSE = { + "error_code": 0, + "result": {"stok": MOCK_UNENCRYPTED_PASSTHROUGH_STOK, "user_group": "root"}, + } + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__( + self, + host, + *, + status_code=200, + status_code_list=None, + want_default_username: bool = False, + do_not_encrypt_response=False, + send_response=None, + sequential_request_delay=0, + send_error_code=0, + secure_passthrough_error_code=0, + digest_password_fail=False, + device_blocked=False, + unencrypted_passthrough=False, + ): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + self.encryption_session: AesEncyptionSession | None = None + self.server_nonce = secrets.token_bytes(8).hex().upper() + self.handshake1_complete = False + + # test behaviour attributes + self.status_code = status_code + self.status_code_list = status_code_list if status_code_list else [] + self.send_error_code = send_error_code + self.secure_passthrough_error_code = secure_passthrough_error_code + self.do_not_encrypt_response = do_not_encrypt_response + self.want_default_username = want_default_username + self.digest_password_fail = digest_password_fail + self.device_blocked = device_blocked + self.unencrypted_passthrough = unencrypted_passthrough + + self._next_responses: list[dict | bytes] = [] + + def _get_status_code(self): + if self.status_code_list: + return self.status_code_list.pop(0) + return self.status_code + + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if data: + json = json_loads(data) + res = await self._post(url, json) + return res + + async def _post(self, url: URL, json: dict[str, Any]): + method = json["method"] + + if method == "login" and not self.handshake1_complete: + return await self._return_handshake1_response(url, json) + + if method == "login" and self.handshake1_complete: + if self.unencrypted_passthrough: + return await self._return_unencrypted_passthrough_login_response( + url, json + ) + + return await self._return_handshake2_response(url, json) + elif method == "securePassthrough": + assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") + return await self._return_secure_passthrough_response(url, json) + else: + # The unencrypted passthrough with have actual query method names. + # This path is also used by the mock class to return unencrypted + # responses to single 'get' queries which the secure fw returns as unencrypted + stok = ( + MOCK_UNENCRYPTED_PASSTHROUGH_STOK + if self.unencrypted_passthrough + else MOCK_STOCK + ) + assert url == URL(f"https://{self.host}/stok={stok}/ds") + return await self._return_send_response(url, json) + + async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): + request_nonce = request["params"].get("cnonce") + request_username = request["params"].get("username") + + if self.device_blocked: + return self._mock_response(self.status_code, self.DEVICE_BLOCKED_RESP) + + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + resp = ( + self.UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP + if self.unencrypted_passthrough + else self.BAD_USER_RESP + ) + return self._mock_response(self.status_code, resp) + + device_confirm = SslAesTransport.generate_confirm_hash( + request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + self.handshake1_complete = True + + if self.unencrypted_passthrough: + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP + ) + + resp = { + "error_code": SmartErrorCode.INVALID_NONCE.value, + "result": { + "data": { + "code": SmartErrorCode.INVALID_NONCE.value, + "encrypt_type": ["3"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": self.server_nonce, + "device_confirm": device_confirm, + } + }, + } + return self._mock_response(self._get_status_code(), resp) + + async def _return_unencrypted_passthrough_login_response( + self, url: URL, request: dict[str, Any] + ): + request_username = request["params"].get("username") + request_password = request["params"].get("password") + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_BAD_USER_RESP + ) + + expected_pwd = _md5_hash(MOCK_PWD.encode()) + if request_password != expected_pwd or self.digest_password_fail: + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_HANDSHAKE_RESP + ) + + return self._mock_response( + self._get_status_code(), self.UNENCRYPTED_PASSTHROUGH_GOOD_LOGIN_RESPONSE + ) + + async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): + request_nonce = request["params"].get("cnonce") + request_username = request["params"].get("username") + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response(self._get_status_code(), self.BAD_USER_RESP) + + request_password = request["params"].get("digest_passwd") + expected_pwd = SslAesTransport.generate_digest_password( + request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + if request_password != expected_pwd or self.digest_password_fail: + return self._mock_response(self._get_status_code(), self.BAD_PWD_RESP) + + lsk = SslAesTransport.generate_encryption_token( + "lsk", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + ivb = SslAesTransport.generate_encryption_token( + "ivb", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + self.encryption_session = AesEncyptionSession(lsk, ivb) + resp = { + "error_code": 0, + "result": {"stok": MOCK_STOCK, "user_group": "root", "start_seq": 100}, + } + return self._mock_response(self._get_status_code(), resp) + + async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]): + encrypted_request = json["params"]["request"] + assert self.encryption_session + decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) + decrypted_request_dict = json_loads(decrypted_request) + + if self._next_responses: + next_response = self._next_responses.pop(0) + if isinstance(next_response, dict): + decrypted_response_data = json_dumps(next_response).encode() + encrypted_response = self.encryption_session.encrypt( + decrypted_response_data + ) + else: + encrypted_response = next_response + else: + decrypted_response = await self._post(url, decrypted_request_dict) + async with decrypted_response: + decrypted_response_data = await decrypted_response.read() + encrypted_response = self.encryption_session.encrypt( + decrypted_response_data + ) + + response = ( + decrypted_response_data + if self.do_not_encrypt_response + else encrypted_response + ) + result = { + "result": {"response": response.decode()}, + "error_code": self.secure_passthrough_error_code, + } + return self._mock_response(self._get_status_code(), result) + + async def _return_send_response(self, url: URL, json: dict[str, Any]): + result = {"result": {"method": None}, "error_code": self.send_error_code} + return self._mock_response(self._get_status_code(), result) + + def put_next_response(self, request: dict | bytes) -> None: + self._next_responses.append(request) diff --git a/tests/transports/test_ssltransport.py b/tests/transports/test_ssltransport.py new file mode 100644 index 000000000..939e2ea64 --- /dev/null +++ b/tests/transports/test_ssltransport.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import logging +from base64 import b64encode +from contextlib import AbstractContextManager +from contextlib import nullcontext as does_not_raise +from typing import Any + +import aiohttp +import pytest +from pytest_mock import MockerFixture +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads +from kasa.transports import SslTransport +from kasa.transports.ssltransport import TransportState, _md5_hash + +# Transport tests are not designed for real devices +pytestmark = [pytest.mark.requires_dummy] + +MOCK_PWD = "correct_pwd" # noqa: S105 +MOCK_USER = "mock@example.com" +MOCK_BAD_USER_OR_PWD = "foobar" # noqa: S105 +MOCK_TOKEN = "abcdefghijklmnopqrstuvwxyz1234)(" # noqa: S105 + +DEFAULT_CREDS = get_default_credentials(DEFAULT_CREDENTIALS["TAPO"]) + + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + ( + "status_code", + "error_code", + "username", + "password", + "expectation", + ), + [ + pytest.param( + 200, + SmartErrorCode.SUCCESS, + MOCK_USER, + MOCK_PWD, + does_not_raise(), + id="success", + ), + pytest.param( + 200, + SmartErrorCode.UNSPECIFIC_ERROR, + MOCK_USER, + MOCK_PWD, + pytest.raises(_RetryableError), + id="test retry", + ), + pytest.param( + 200, + SmartErrorCode.DEVICE_BLOCKED, + MOCK_USER, + MOCK_PWD, + pytest.raises(DeviceError), + id="test regular error", + ), + pytest.param( + 400, + SmartErrorCode.INTERNAL_UNKNOWN_ERROR, + MOCK_USER, + MOCK_PWD, + pytest.raises(KasaException), + id="400 error", + ), + pytest.param( + 200, + SmartErrorCode.LOGIN_ERROR, + MOCK_BAD_USER_OR_PWD, + MOCK_PWD, + pytest.raises(AuthenticationError), + id="bad-username", + ), + pytest.param( + 200, + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SUCCESS], + MOCK_BAD_USER_OR_PWD, + "", + does_not_raise(), + id="working-fallback", + ), + pytest.param( + 200, + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR], + MOCK_BAD_USER_OR_PWD, + "", + pytest.raises(AuthenticationError), + id="fallback-fail", + ), + pytest.param( + 200, + SmartErrorCode.LOGIN_ERROR, + MOCK_USER, + MOCK_BAD_USER_OR_PWD, + pytest.raises(AuthenticationError), + id="bad-password", + ), + pytest.param( + 200, + SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, + MOCK_USER, + MOCK_PWD, + pytest.raises(AuthenticationError), + id="auth-error != login_error", + ), + ], +) +async def test_login( + mocker: MockerFixture, + status_code: int, + error_code: SmartErrorCode | list[SmartErrorCode], + username: str, + password: str, + expectation: AbstractContextManager, +) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice( + host, + status_code=status_code, + send_error_code=error_code, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport( + config=DeviceConfig(host, credentials=Credentials(username, password)) + ) + + assert transport._state is TransportState.LOGIN_REQUIRED + with expectation: + await transport.perform_login() + assert transport._state is TransportState.ESTABLISHED + + await transport.close() + + +async def test_credentials_hash(mocker: MockerFixture) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + creds = Credentials(MOCK_USER, MOCK_PWD) + + data = {"password": _md5_hash(MOCK_PWD.encode()), "username": MOCK_USER} + + creds_hash = b64encode(json_dumps(data).encode()).decode() + + # Test with credentials input + transport = SslTransport(config=DeviceConfig(host, credentials=creds)) + assert transport.credentials_hash == creds_hash + + # Test with credentials_hash input + transport = SslTransport(config=DeviceConfig(host, credentials_hash=creds_hash)) + assert transport.credentials_hash == creds_hash + + await transport.close() + + +async def test_send(mocker: MockerFixture) -> None: + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + try_login_spy = mocker.spy(transport, "try_login") + request = { + "method": "get_device_info", + "params": None, + } + assert transport._state is TransportState.LOGIN_REQUIRED + + res = await transport.send(json_dumps(request)) + assert "result" in res + try_login_spy.assert_called_once() + assert transport._state is TransportState.ESTABLISHED + + # Second request does not + res = await transport.send(json_dumps(request)) + try_login_spy.assert_called_once() + + await transport.close() + + +async def test_no_credentials(mocker: MockerFixture) -> None: + """Test transport without credentials.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice( + host, send_error_code=SmartErrorCode.LOGIN_ERROR + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport(config=DeviceConfig(host)) + try_login_spy = mocker.spy(transport, "try_login") + + with pytest.raises(AuthenticationError): + await transport.send('{"method": "dummy"}') + + # We get called twice + assert try_login_spy.call_count == 2 + + await transport.close() + + +async def test_reset(mocker: MockerFixture) -> None: + """Test that transport state adjusts correctly for reset.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + assert transport._state is TransportState.LOGIN_REQUIRED + assert str(transport._app_url) == "https://127.0.0.1:4433/app" + + await transport.perform_login() + assert transport._state is TransportState.ESTABLISHED + assert str(transport._app_url).startswith("https://127.0.0.1:4433/app?token=") + + await transport.close() + assert transport._state is TransportState.LOGIN_REQUIRED + assert str(transport._app_url) == "https://127.0.0.1:4433/app" + + +async def test_port_override() -> None: + """Test that port override sets the app_url.""" + host = "127.0.0.1" + port_override = 12345 + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=port_override + ) + transport = SslTransport(config=config) + + assert str(transport._app_url) == f"https://127.0.0.1:{port_override}/app" + + await transport.close() + + +class MockSslDevice: + """Based on MockAesSslDevice.""" + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__( + self, + host, + *, + status_code=200, + send_error_code=SmartErrorCode.INTERNAL_UNKNOWN_ERROR, + ): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + + self._state = TransportState.LOGIN_REQUIRED + + # test behaviour attributes + self.status_code = status_code + self.send_error_code = send_error_code + + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if data: + json = json_loads(data) + _LOGGER.debug("Request %s: %s", url, json) + res = self._post(url, json) + _LOGGER.debug("Response %s, data: %s", res, await res.read()) + return res + + def _post(self, url: URL, json: dict[str, Any]): + method = json["method"] + + if method == "login": + if self._state is TransportState.LOGIN_REQUIRED: + assert json.get("token") is None + assert url == URL(f"https://{self.host}:4433/app") + return self._return_login_response(url, json) + else: + _LOGGER.warning("Received login although already logged in") + pytest.fail("non-handled re-login logic") + + assert url == URL(f"https://{self.host}:4433/app?token={MOCK_TOKEN}") + return self._return_send_response(url, json) + + def _return_login_response(self, url: URL, request: dict[str, Any]): + request_username = request["params"].get("username") + request_password = request["params"].get("password") + + # Handle multiple error codes + if isinstance(self.send_error_code, list): + error_code = self.send_error_code.pop(0) + else: + error_code = self.send_error_code + + _LOGGER.debug("Using error code %s", error_code) + + def _return_login_error(): + resp = { + "error_code": error_code.value, + "result": {"unknown": "payload"}, + } + + _LOGGER.debug("Returning login error with status %s", self.status_code) + return self._mock_response(self.status_code, resp) + + if error_code is not SmartErrorCode.SUCCESS: + # Bad username + if request_username == MOCK_BAD_USER_OR_PWD: + return _return_login_error() + + # Bad password + if request_password == _md5_hash(MOCK_BAD_USER_OR_PWD.encode()): + return _return_login_error() + + # Empty password + if request_password == _md5_hash(b""): + return _return_login_error() + + self._state = TransportState.ESTABLISHED + resp = { + "error_code": error_code.value, + "result": { + "token": MOCK_TOKEN, + }, + } + _LOGGER.debug("Returning login success with status %s", self.status_code) + return self._mock_response(self.status_code, resp) + + def _return_send_response(self, url: URL, json: dict[str, Any]): + method = json["method"] + result = { + "result": {method: {"dummy": "response"}}, + "error_code": self.send_error_code.value, + } + return self._mock_response(self.status_code, result) diff --git a/uv.lock b/uv.lock index c68023301..e32696bd6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,18 +1,23 @@ version = 1 +revision = 3 requires-python = ">=3.11, <4.0" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "aiohappyeyeballs" -version = "2.4.3" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/69/2f6d5a019bd02e920a3417689a89887b39ad1e350b562f9955693d900c40/aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", size = 21809 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/d8/120cd0fe3e8530df0539e71ba9683eade12cae103dd7543e50d15f737917/aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572", size = 14742 }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] name = "aiohttp" -version = "3.11.7" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -21,246 +26,349 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/f9bb10e0cf6f01730b27d370b10cc15822bea4395acd687abc8cc5fed3ed/aiohttp-3.11.7.tar.gz", hash = "sha256:01a8aca4af3da85cea5c90141d23f4b0eee3cbecfd33b029a45a80f28c66c668", size = 7666482 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/7f/272fa1adf68fe2fbebfe686a67b50cfb40d86dfe47d0441aff6f0b7c4c0e/aiohttp-3.11.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea52d11e02123f125f9055dfe0ccf1c3857225fb879e4a944fae12989e2aef2", size = 706820 }, - { url = "https://files.pythonhosted.org/packages/79/3c/6d612ef77cdba75364393f04c5c577481e3b5123a774eea447ada1ddd14f/aiohttp-3.11.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3ce18f703b7298e7f7633efd6a90138d99a3f9a656cb52c1201e76cb5d79cf08", size = 466654 }, - { url = "https://files.pythonhosted.org/packages/4f/b8/1052667d4800cd49bb4f869f1ed42f5e9d5acd4676275e64ccc244c9c040/aiohttp-3.11.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:670847ee6aeb3a569cd7cdfbe0c3bec1d44828bbfbe78c5d305f7f804870ef9e", size = 454041 }, - { url = "https://files.pythonhosted.org/packages/9f/07/80fa7302314a6ee1c9278550e9d95b77a4c895999bfbc5364ed0ee28dc7c/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dda726f89bfa5c465ba45b76515135a3ece0088dfa2da49b8bb278f3bdeea12", size = 1684778 }, - { url = "https://files.pythonhosted.org/packages/2e/30/a71eb45197ad6bb6af87dfb39be8b56417d24d916047d35ef3f164af87f4/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25b74a811dba37c7ea6a14d99eb9402d89c8d739d50748a75f3cf994cf19c43", size = 1740992 }, - { url = "https://files.pythonhosted.org/packages/22/74/0f9394429f3c4197129333a150a85cb2a642df30097a39dd41257f0b3bdc/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5522ee72f95661e79db691310290c4618b86dff2d9b90baedf343fd7a08bf79", size = 1781816 }, - { url = "https://files.pythonhosted.org/packages/7f/1a/1e256b39179c98d16d53ac62f64bfcfe7c5b2c1e68b83cddd4165854524f/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fbf41a6bbc319a7816ae0f0177c265b62f2a59ad301a0e49b395746eb2a9884", size = 1676692 }, - { url = "https://files.pythonhosted.org/packages/9b/37/f19d2e00efcabb9183b16bd91244de1d9c4ff7bf0fb5b8302e29a78f3286/aiohttp-3.11.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59ee1925b5a5efdf6c4e7be51deee93984d0ac14a6897bd521b498b9916f1544", size = 1619523 }, - { url = "https://files.pythonhosted.org/packages/ae/3c/af50cf5e06b98783fd776f17077f7b7e755d461114af5d6744dc037fc3b0/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:24054fce8c6d6f33a3e35d1c603ef1b91bbcba73e3f04a22b4f2f27dac59b347", size = 1644084 }, - { url = "https://files.pythonhosted.org/packages/c0/a6/4e0233b085cbf2b6de573515c1eddde82f1c1f17e69347e32a5a5f2617ff/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:351849aca2c6f814575c1a485c01c17a4240413f960df1bf9f5deb0003c61a53", size = 1648332 }, - { url = "https://files.pythonhosted.org/packages/06/20/7062e76e7817318c421c0f9d7b650fb81aaecf6d2f3a9833805b45ec2ea8/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:12724f3a211fa243570e601f65a8831372caf1a149d2f1859f68479f07efec3d", size = 1730912 }, - { url = "https://files.pythonhosted.org/packages/6c/1c/ff6ae4b1789894e6faf8a4e260cd3861cad618dc80ad15326789a7765750/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7ea4490360b605804bea8173d2d086b6c379d6bb22ac434de605a9cbce006e7d", size = 1752619 }, - { url = "https://files.pythonhosted.org/packages/33/58/ddd5cba5ca245c00b04e9d28a7988b0f0eda02de494f8e62ecd2780655c2/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0bf378db07df0a713a1e32381a1b277e62ad106d0dbe17b5479e76ec706d720", size = 1692801 }, - { url = "https://files.pythonhosted.org/packages/b2/fc/32d5e2070b43d3722b7ea65ddc6b03ffa39bcc4b5ab6395a825cde0872ad/aiohttp-3.11.7-cp311-cp311-win32.whl", hash = "sha256:cd8d62cab363dfe713067027a5adb4907515861f1e4ce63e7be810b83668b847", size = 414899 }, - { url = "https://files.pythonhosted.org/packages/ec/7e/50324c6d3df4540f5963def810b9927f220c99864065849a1dfcae77a6ce/aiohttp-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:bf0e6cce113596377cadda4e3ac5fb89f095bd492226e46d91b4baef1dd16f60", size = 440938 }, - { url = "https://files.pythonhosted.org/packages/bf/1e/2e96b2526c590dcb99db0b94ac4f9b927ecc07f94735a8a941dee143d48b/aiohttp-3.11.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4bb7493c3e3a36d3012b8564bd0e2783259ddd7ef3a81a74f0dbfa000fce48b7", size = 702326 }, - { url = "https://files.pythonhosted.org/packages/b5/ce/b5d7f3e68849f1f5e0b85af4ac9080b9d3c0a600857140024603653c2209/aiohttp-3.11.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e143b0ef9cb1a2b4f74f56d4fbe50caa7c2bb93390aff52f9398d21d89bc73ea", size = 461944 }, - { url = "https://files.pythonhosted.org/packages/28/fa/f4d98db1b7f8f0c3f74bdbd6d0d98cfc89984205cd33f1b8ee3f588ee5ad/aiohttp-3.11.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7c58a240260822dc07f6ae32a0293dd5bccd618bb2d0f36d51c5dbd526f89c0", size = 454348 }, - { url = "https://files.pythonhosted.org/packages/04/f0/c238dda5dc9a3d12b76636e2cf0ea475890ac3a1c7e4ff0fd6c3cea2fc2d/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d20cfe63a1c135d26bde8c1d0ea46fd1200884afbc523466d2f1cf517d1fe33", size = 1678795 }, - { url = "https://files.pythonhosted.org/packages/79/ee/3a18f792247e6d95dba13aaedc9dc317c3c6e75f4b88c2dd4b960d20ad2f/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12e4d45847a174f77b2b9919719203769f220058f642b08504cf8b1cf185dacf", size = 1734411 }, - { url = "https://files.pythonhosted.org/packages/f5/79/3eb84243087a9a32cae821622c935107b4b55a5b21b76772e8e6c41092e9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4efa2d01f697a7dbd0509891a286a4af0d86902fc594e20e3b1712c28c0106", size = 1788959 }, - { url = "https://files.pythonhosted.org/packages/91/93/ad77782c5edfa17aafc070bef978fbfb8459b2f150595ffb01b559c136f9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee6a4cdcbf54b8083dc9723cdf5f41f722c00db40ccf9ec2616e27869151129", size = 1687463 }, - { url = "https://files.pythonhosted.org/packages/ba/48/db35bd21b7877efa0be5f28385d8978c55323c5ce7685712e53f3f6c0bd9/aiohttp-3.11.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6095aaf852c34f42e1bd0cf0dc32d1e4b48a90bfb5054abdbb9d64b36acadcb", size = 1618374 }, - { url = "https://files.pythonhosted.org/packages/ba/77/30f87db55c79fd145ed5fd15b92f2e820ce81065d41ae437797aaa550e3b/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1cf03d27885f8c5ebf3993a220cc84fc66375e1e6e812731f51aab2b2748f4a6", size = 1637021 }, - { url = "https://files.pythonhosted.org/packages/af/76/10b188b78ee18d0595af156d6a238bc60f9d8571f0f546027eb7eaf65b25/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1a17f6a230f81eb53282503823f59d61dff14fb2a93847bf0399dc8e87817307", size = 1650792 }, - { url = "https://files.pythonhosted.org/packages/fa/33/4411bbb8ad04c47d0f4c7bd53332aaf350e49469cf6b65b132d4becafe27/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:481f10a1a45c5f4c4a578bbd74cff22eb64460a6549819242a87a80788461fba", size = 1696248 }, - { url = "https://files.pythonhosted.org/packages/fe/2d/6135d0dc1851a33d3faa937b20fef81340bc95e8310536d4c7f1f8ecc026/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:db37248535d1ae40735d15bdf26ad43be19e3d93ab3f3dad8507eb0f85bb8124", size = 1729188 }, - { url = "https://files.pythonhosted.org/packages/f5/76/a57ceff577ae26fe9a6f31ac799bc638ecf26e4acdf04295290b9929b349/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d18a8b44ec8502a7fde91446cd9c9b95ce7c49f1eacc1fb2358b8907d4369fd", size = 1690038 }, - { url = "https://files.pythonhosted.org/packages/4b/81/b20e09003b6989a7f23a721692137a6143420a151063c750ab2a04878e3c/aiohttp-3.11.7-cp312-cp312-win32.whl", hash = "sha256:3d1c9c15d3999107cbb9b2d76ca6172e6710a12fda22434ee8bd3f432b7b17e8", size = 409887 }, - { url = "https://files.pythonhosted.org/packages/b7/0b/607c98bff1d07bb21e0c39e7711108ef9ff4f2a361a3ec1ce8dce93623a5/aiohttp-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:018f1b04883a12e77e7fc161934c0f298865d3a484aea536a6a2ca8d909f0ba0", size = 436462 }, - { url = "https://files.pythonhosted.org/packages/7a/53/8d77186c6a33bd087714df18274cdcf6e36fd69a9e841c85b7e81a20b18e/aiohttp-3.11.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:241a6ca732d2766836d62c58c49ca7a93d08251daef0c1e3c850df1d1ca0cbc4", size = 695811 }, - { url = "https://files.pythonhosted.org/packages/62/b6/4c3d107a5406aa6f99f618afea82783f54ce2d9644020f50b9c88f6e823d/aiohttp-3.11.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa3705a8d14de39898da0fbad920b2a37b7547c3afd2a18b9b81f0223b7d0f68", size = 458530 }, - { url = "https://files.pythonhosted.org/packages/d9/05/dbf0bd3966be8ebed3beb4007a2d1356d79af4fe7c93e54f984df6385193/aiohttp-3.11.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9acfc7f652b31853eed3b92095b0acf06fd5597eeea42e939bd23a17137679d5", size = 451371 }, - { url = "https://files.pythonhosted.org/packages/19/6a/2198580314617b6cf9c4b813b84df5832b5f8efedcb8a7e8b321a187233c/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcefcf2915a2dbdbce37e2fc1622129a1918abfe3d06721ce9f6cdac9b6d2eaa", size = 1662905 }, - { url = "https://files.pythonhosted.org/packages/2b/65/08696fd7503f6a6f9f782bd012bf47f36d4ed179a7d8c95dba4726d5cc67/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c1f6490dd1862af5aae6cfcf2a274bffa9a5b32a8f5acb519a7ecf5a99a88866", size = 1713794 }, - { url = "https://files.pythonhosted.org/packages/c8/a3/b9a72dce6f15e2efbc09fa67c1067c4f3a3bb05661c0ae7b40799cde02b7/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac5462582d6561c1c1708853a9faf612ff4e5ea5e679e99be36143d6eabd8e", size = 1770757 }, - { url = "https://files.pythonhosted.org/packages/78/7e/8fb371b5f8c4c1eaa0d0a50750c0dd68059f86794aeb36919644815486f5/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1a6309005acc4b2bcc577ba3b9169fea52638709ffacbd071f3503264620da", size = 1673136 }, - { url = "https://files.pythonhosted.org/packages/2f/0f/09685d13d2c7634cb808868ea29c170d4dcde4215a4a90fb86491cd3ae25/aiohttp-3.11.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b973cce96793725ef63eb449adfb74f99c043c718acb76e0d2a447ae369962", size = 1600370 }, - { url = "https://files.pythonhosted.org/packages/00/2e/18fd38b117f9b3a375166ccb70ed43cf7e3dfe2cc947139acc15feefc5a2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ce91a24aac80de6be8512fb1c4838a9881aa713f44f4e91dd7bb3b34061b497d", size = 1613459 }, - { url = "https://files.pythonhosted.org/packages/2c/94/10a82abc680d753be33506be699aaa330152ecc4f316eaf081f996ee56c2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:875f7100ce0e74af51d4139495eec4025affa1a605280f23990b6434b81df1bd", size = 1613924 }, - { url = "https://files.pythonhosted.org/packages/e9/58/897c0561f5c522dda6e173192f1e4f10144e1a7126096f17a3f12b7aa168/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c171fc35d3174bbf4787381716564042a4cbc008824d8195eede3d9b938e29a8", size = 1681164 }, - { url = "https://files.pythonhosted.org/packages/8b/8b/3a48b1cdafa612679d976274355f6a822de90b85d7dba55654ecfb01c979/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ee9afa1b0d2293c46954f47f33e150798ad68b78925e3710044e0d67a9487791", size = 1712139 }, - { url = "https://files.pythonhosted.org/packages/aa/9d/70ab5b4dd7900db04af72840e033aee06e472b1343e372ea256ed675511c/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8360c7cc620abb320e1b8d603c39095101391a82b1d0be05fb2225471c9c5c52", size = 1667446 }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5fbcc8f6056f0c56001c75227e6b7ca9ee4f2e5572feca82ff3d65d485d/aiohttp-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7a9318da4b4ada9a67c1dd84d1c0834123081e746bee311a16bb449f363d965e", size = 408689 }, - { url = "https://files.pythonhosted.org/packages/ef/07/4d1504577fa6349dd2e3839e89fb56e5dee38d64efe3d4366e9fcfda0cdb/aiohttp-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:fc6da202068e0a268e298d7cd09b6e9f3997736cd9b060e2750963754552a0a9", size = 434809 }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] name = "aiosignal" -version = "1.3.1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/67/0952ed97a9793b4958e5736f6d2b346b414a2cd63e82d05940032f45b32f/aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", size = 19422 } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17", size = 7617 }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "alabaster" version = "0.7.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511 }, -] - -[[package]] -name = "anyio" -version = "4.6.2.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, ] [[package]] name = "appdirs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] [[package]] name = "asyncclick" -version = "8.1.7.2" +version = "8.4.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/59d836c3433d7aa07f76c2b95c4eb763195ea8a5d7f9ad3311ed30c2af61/asyncclick-8.1.7.2.tar.gz", hash = "sha256:219ea0f29ccdc1bb4ff43bcab7ce0769ac6d48a04f997b43ec6bee99a222daa0", size = 349073 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/b2/2c90a32d9b9cf1a4a90ed39cc1177890d146f3f89ae375b061d72110659a/asyncclick-8.4.2.1.tar.gz", hash = "sha256:8f259376ae8d4fcd91961c8ad3e85e9b63c9cf181a122201c7dfed3d7f6024b8", size = 340830, upload-time = "2026-06-30T12:40:33.842Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/6e/9acdbb25733e1de411663b59abe521bec738e72fe4e85843f6ff8b212832/asyncclick-8.1.7.2-py3-none-any.whl", hash = "sha256:1ab940b04b22cb89b5b400725132b069d01b0c3472a9702c7a2c9d5d007ded02", size = 99191 }, + { url = "https://files.pythonhosted.org/packages/a7/44/ee9baab53fe4ff487fa47a1c50b174d3b3fb583cf2e22ff5ee33f61be899/asyncclick-8.4.2.1-py3-none-any.whl", hash = "sha256:dbd533769c4e3ce831c05394027ea1cd606c09688e18abab13dbcfbf7dc5a142", size = 121917, upload-time = "2026-06-30T12:40:32.213Z" }, ] [[package]] name = "attrs" -version = "24.2.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "babel" -version = "2.16.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "certifi" -version = "2024.8.30" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "cffi" -version = "1.17.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, - { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, - { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, - { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, - { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, - { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, - { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, - { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, - { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, - { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, - { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, - { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, - { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, - { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, - { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, - { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, - { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, - { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, - { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, - { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, - { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, - { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, - { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, - { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, - { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, - { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, - { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, - { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, - { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, - { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -271,66 +379,102 @@ dependencies = [ { name = "coverage" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/bb/594b26d2c85616be6195a64289c578662678afa4910cef2d3ce8417cf73e/codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", size = 21416 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/bb/594b26d2c85616be6195a64289c578662678afa4910cef2d3ce8417cf73e/codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", size = 21416, upload-time = "2023-04-17T23:11:39.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512 }, + { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512, upload-time = "2023-04-17T23:11:37.344Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" -version = "7.6.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/9f/e98211980f6e2f439e251737482aa77906c9b9c507824c71a2ce7eea0402/coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4", size = 207093 }, - { url = "https://files.pythonhosted.org/packages/fd/c7/8bab83fb9c20f7f8163c5a20dcb62d591b906a214a6dc6b07413074afc80/coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94", size = 207536 }, - { url = "https://files.pythonhosted.org/packages/1e/d6/00243df625f1b282bb25c83ce153ae2c06f8e7a796a8d833e7235337b4d9/coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4", size = 239482 }, - { url = "https://files.pythonhosted.org/packages/1e/07/faf04b3eeb55ffc2a6f24b65dffe6e0359ec3b283e6efb5050ea0707446f/coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1", size = 236886 }, - { url = "https://files.pythonhosted.org/packages/43/23/c79e497bf4d8fcacd316bebe1d559c765485b8ec23ac4e23025be6bfce09/coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb", size = 238749 }, - { url = "https://files.pythonhosted.org/packages/b5/e5/791bae13be3c6451e32ef7af1192e711c6a319f3c597e9b218d148fd0633/coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8", size = 237679 }, - { url = "https://files.pythonhosted.org/packages/05/c6/bbfdfb03aada601fb8993ced17468c8c8e0b4aafb3097026e680fabb7ce1/coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a", size = 236317 }, - { url = "https://files.pythonhosted.org/packages/67/f9/f8e5a4b2ce96d1b0e83ae6246369eb8437001dc80ec03bb51c87ff557cd8/coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0", size = 237084 }, - { url = "https://files.pythonhosted.org/packages/f0/70/b05328901e4debe76e033717e1452d00246c458c44e9dbd893e7619c2967/coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801", size = 209638 }, - { url = "https://files.pythonhosted.org/packages/70/55/1efa24f960a2fa9fbc44a9523d3f3c50ceb94dd1e8cd732168ab2dc41b07/coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9", size = 210506 }, - { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 }, - { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 }, - { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 }, - { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 }, - { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 }, - { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 }, - { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 }, - { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 }, - { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 }, - { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 }, - { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 }, - { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 }, - { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 }, - { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 }, - { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 }, - { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 }, - { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 }, - { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 }, - { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 }, - { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 }, - { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 }, - { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 }, - { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 }, - { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 }, - { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 }, - { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 }, - { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 }, - { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 }, - { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 }, - { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [package.optional-dependencies] @@ -340,215 +484,405 @@ toml = [ [[package]] name = "cryptography" -version = "43.0.3" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303 }, - { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905 }, - { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271 }, - { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606 }, - { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484 }, - { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131 }, - { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647 }, - { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873 }, - { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039 }, - { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984 }, - { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968 }, - { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754 }, - { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458 }, - { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220 }, - { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898 }, - { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, - { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145 }, - { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026 }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] name = "distlib" -version = "0.3.9" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] name = "docutils" version = "0.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 }, + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" }, ] [[package]] name = "execnet" -version = "2.1.1" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "filelock" -version = "3.16.1" +version = "3.29.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ee/29c668c50888588c432a702f7c2e8ee8a0c9e5286028d91f170308d6b2e9/filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", size = 68927, upload-time = "2026-07-03T03:50:31.818Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, + { url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" }, ] [[package]] name = "freezegun" -version = "1.5.1" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 } +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] [[package]] name = "frozenlist" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 }, - { url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 }, - { url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 }, - { url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357 }, - { url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516 }, - { url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131 }, - { url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320 }, - { url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877 }, - { url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592 }, - { url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934 }, - { url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859 }, - { url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560 }, - { url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150 }, - { url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244 }, - { url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634 }, - { url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 }, - { url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 }, - { url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 }, - { url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 }, - { url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 }, - { url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 }, - { url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 }, - { url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 }, - { url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 }, - { url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 }, - { url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 }, - { url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 }, - { url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 }, - { url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 }, - { url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 }, - { url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 }, - { url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 }, - { url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 }, - { url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 }, - { url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 }, - { url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 }, - { url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 }, - { url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 }, - { url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 }, - { url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 }, - { url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 }, - { url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 }, - { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 }, - { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 }, - { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 }, - { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "identify" -version = "2.6.3" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jedi" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "kasa-crypt" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ba/f78a63c5b55dc18b39099a1a1bf6569c14ccca47dd342cc4f4d774ec5719/kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd", size = 9306 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/d9e9b54aad36d8aae9f59adc8ddb27bf7a06f505deffe98f28bc865ba494/kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d", size = 69934 }, - { url = "https://files.pythonhosted.org/packages/15/79/5e94eb76f2935f92de9602b04d0c244653540128eba2be71e6284f9c9997/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9", size = 133178 }, - { url = "https://files.pythonhosted.org/packages/7a/1e/3836b1e69da964e3c8dbf057d82f8f13d277fe9baa6c327400ea5ebc37e1/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2", size = 136881 }, - { url = "https://files.pythonhosted.org/packages/aa/24/eeafbbdc5a914abdd8911108eab7fe3ddf5bfdd1e14d3d43f5874a936863/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa2bcbf7c4bb2af4a86c553fb8df47466c06f5060d5c21253a4ecd9ee2237ef4", size = 136189 }, - { url = "https://files.pythonhosted.org/packages/69/23/6c0604c093f69f80d00b8953ec7ac0cfc4db2504db7cddf7be26f6ed582d/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:99518489cb93d93c6c2e5ac4e30ad6838bb64c8365e8c3a37204e7f4228805ca", size = 139644 }, - { url = "https://files.pythonhosted.org/packages/c4/54/13e48c5b280600c966cba23b1940d38ec2847db909f060224c902af33c5c/kasa_crypt-0.4.4-cp311-cp311-win32.whl", hash = "sha256:431223a614f868a253786da7b137a8597c8ce83ed71a8bc10ffe9e56f7a8ba4d", size = 68754 }, - { url = "https://files.pythonhosted.org/packages/02/eb/aa085ddebda8c1d2912e5c6196f3c9106595c6dae2098bcb5df602db978f/kasa_crypt-0.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:c3d60a642985c3c7c9b598e19da537566803d2f78a42d0be5a7231d717239f11", size = 70959 }, - { url = "https://files.pythonhosted.org/packages/aa/f6/de1ecffa3b69200a9ebeb423f8bdb3a46987508865c906c50c09f18e311f/kasa_crypt-0.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:038a16270b15d9a9845ad4ba66f76cbf05109855e40afb6a62d7b99e73ba55a3", size = 70165 }, - { url = "https://files.pythonhosted.org/packages/8a/9a/a43be44b356bb97f7a6213c7a87863c4f7f85c9137e75fb95d66e3f04d9b/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5cc150ef1bd2a330903557f806e7b671fe59f15fd37337f69ea0d7872cbffdde", size = 139126 }, - { url = "https://files.pythonhosted.org/packages/0a/52/b6e8ee4bb8aea9735da157918342baa98bf3cc8e725d74315cd33a62374a/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c45838d4b361f76615be72ee9b238681c47330f09cc3b0eb830095b063a262c2", size = 143953 }, - { url = "https://files.pythonhosted.org/packages/b0/cb/2c10cb2534a1237c46f4e9d764e74f5f8e3eb84862fa656629e8f1b3ebb9/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:138479985246ebc6be5d9bb896e48860d72a280e068d798af93acd2a210031c1", size = 141496 }, - { url = "https://files.pythonhosted.org/packages/38/62/9bcf83c27ddfaa50353deb4c9793873356d7c4b99c3b073a1c623eda883c/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742", size = 146398 }, - { url = "https://files.pythonhosted.org/packages/d5/63/ad0de4d97f9ec2e290a9ed37756c70ad5c99403f62399a4f9fafeb3d8c81/kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0", size = 68951 }, - { url = "https://files.pythonhosted.org/packages/44/ce/a843f0a2c3328d792a41ca6261c1564af188a4f15b1af34f83ec8c68c686/kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699", size = 71352 }, +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/40/ec872ba2b9881752e3f41e195411f069f454488b17eae42795eac9d04958/kasa_crypt-1.1.0.tar.gz", hash = "sha256:78305efe8b6dee12e3b3ecc7b7a6ae4c46bc4c3234b2cdfd20df03d82a9d818a", size = 9095, upload-time = "2025-10-04T18:43:35.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/45/200cef07cb4cc9fd2db888d4bb07a0b05d9164b5ac5b8aaaa2abfeca93d1/kasa_crypt-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b069a5956e819773daaf02d063671215ad0ab26430ead1aa92b53577b5a4742", size = 80635, upload-time = "2025-10-04T18:53:45.764Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/51af4c1e584a002dba74201f3e2c40c84198093f21e101bc8613f1fe88d9/kasa_crypt-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:deffd1498e0a787053eb880747f9207cbbedbcbe071ef6bb663b5bf1dd67b39d", size = 81379, upload-time = "2025-10-04T18:53:47.144Z" }, + { url = "https://files.pythonhosted.org/packages/4a/17/4aec7065d0399a9dabc2aa1b687f3c6058107061d83726ebddae65a24b32/kasa_crypt-1.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c2970ee8047fd08743ce7037b78488258aaa563643b541f24b7785a307f86a26", size = 84035, upload-time = "2025-10-04T18:53:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/e4d9824a3f8b4b7b186cfb300f703c67af30af997d9b8580d3c0e58d6af0/kasa_crypt-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ebf125e6e496d0cf1ac923e7dbf64f9f124a5fecdf93fdda5ff501d098d0188", size = 84581, upload-time = "2025-10-04T18:53:49.771Z" }, + { url = "https://files.pythonhosted.org/packages/99/8c/d0cf25bd30115e631f416cacbc4c49654383e9a93d7445387a89b978d597/kasa_crypt-1.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0aaac212739cf4b0abe92f1494525a9a6f29fe637118f5005cdcc7fb4a2d106e", size = 24943, upload-time = "2025-10-04T18:53:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8a/e92d3351667c17c6765650439e1b4d079d669b08f3c2bfa80ea12fe9ef0f/kasa_crypt-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:062a7e2905f5553cea0734e310dfd1b8a33c44275039f894c28b5390cfaab9f8", size = 84542, upload-time = "2025-10-04T18:53:52.051Z" }, + { url = "https://files.pythonhosted.org/packages/25/09/4a4f6f892b0fd29a240b8e72e138237053695aa27db68d97569c2d80f891/kasa_crypt-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:084fe45a711cc506947bb95859d78e147a28398825036bfb9a6d30828fe37968", size = 25316, upload-time = "2025-10-04T18:53:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/23/21/d65aabef43b2e96b7a3571f88b0612013d9ee25ab4da519b79f8ef404a83/kasa_crypt-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b68793aab8483b613b3dc83a49ae19874e385a789c75ebc0fc3de48b3c30fd7", size = 84505, upload-time = "2025-10-04T18:53:53.921Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/bdbadcdbd6233296d20347e8b2a1034b3e98f3efc569cb09f84de05ed351/kasa_crypt-1.1.0-cp311-cp311-win32.whl", hash = "sha256:6a744c83bdb16c1dcd94bd1fdc232cb8802546345f08de36fbc54e26477d00df", size = 80494, upload-time = "2025-10-04T18:53:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/87449cdbfd4219befaf1610d9a11a507517d0c624c482a4003fbbcea5b54/kasa_crypt-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:f9b77cf6e16a4a37e6c507a88d18daa7e2ab174de1ed07b7ca4d4678869df93f", size = 82255, upload-time = "2025-10-04T18:53:56.167Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/f7a46da4879b74850b102ebc1d6612dd733aa54fcd5626d3844667f7e780/kasa_crypt-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:307e862d332ae935a29d19af359386c3131947bef6fbf4bf092d9490c848892e", size = 80956, upload-time = "2025-10-04T18:53:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/83806ac99ca0d66124ee662c766cfbcce67e981c5028b4074ed8d8c6aa95/kasa_crypt-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:84ddfaefe94a2503f5436eea80af1c932ff3a8ff8bf80c508d7c08c3295e74ce", size = 81680, upload-time = "2025-10-04T18:53:58.884Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d0/b22d39c360784d06ffe2cab6bbcf96d5362397f0b4689b167432dda6806a/kasa_crypt-1.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92e2b23d7f72d2d743b96c9bd393f2fffc8c3c5247e8b6c9e04386d1c7a7e325", size = 84679, upload-time = "2025-10-04T18:53:59.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/7e/25d4490ff52fa3db613c01c508f4acf678f55d720c710357d89dc4d814bf/kasa_crypt-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa284c343b8d779f1bb617db815a15d67b41e60a7e896b16d35bf8a9c449c7", size = 85231, upload-time = "2025-10-04T18:54:01.18Z" }, + { url = "https://files.pythonhosted.org/packages/19/33/02021171c5c8af6acc01039b1086bce9a0a410c4583a6c947c0d3c6f7557/kasa_crypt-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:feabca7fc9ee84e5bb703b1d069f3d2fa8d9f32384e59dded767fd66c5ee439d", size = 25639, upload-time = "2025-10-04T18:54:02.133Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/6b66441895484aecdb1fa9c468392530a75ea43c3c20997cfe055bbc1f8f/kasa_crypt-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02cb974069669f5de32cf88ab6623d5936acf42c11897045e89828484a6412b8", size = 85093, upload-time = "2025-10-04T18:54:03.066Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e0/661c1011bd60d454c1e65bde18553de7ae262e615da2f875a6524e346200/kasa_crypt-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5af0b01a4a3075de30360ecf80c9a2e7677099bd0b0cfa8983100568accd9263", size = 26153, upload-time = "2025-10-04T18:54:04.027Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dd/373445d294afb92845d30c321257dac93152cf83e4b88777e1f3562e1f10/kasa_crypt-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b15caf6f44eb06d2cdc80889ef4082569928bac907da3cb563272db06bc5cb25", size = 85183, upload-time = "2025-10-04T18:54:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/c6/86/077286d134dc07e451853f33eb71b87f38c3e2709a77e81be2665dc88bdf/kasa_crypt-1.1.0-cp312-cp312-win32.whl", hash = "sha256:40e47489b9005ce815859d9f9ecd1bff3c16ec945620ded4c1e98ddb96658f0f", size = 80813, upload-time = "2025-10-04T18:54:06.264Z" }, + { url = "https://files.pythonhosted.org/packages/66/c1/ef3a4aa555aaf685d61609453d601e52fb26c577efdf7eff744f0dc5ca5b/kasa_crypt-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:1658bc6e6c31b7e886549287fcdae511872de1736bab4f674c6ff4dd11878a2f", size = 82660, upload-time = "2025-10-04T18:54:07.222Z" }, + { url = "https://files.pythonhosted.org/packages/31/96/e196a9c70758dcc4d21763e7092c603caf8bd917ecaf6cc466dedd98d9ea/kasa_crypt-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2a69eb7ec2ad2457ebcf83cd0da943eef06d603451442ad94fa0a0ff057cc8de", size = 80901, upload-time = "2025-10-04T18:54:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b9/e207c7ebd6f891c96dc47ae364f26bfa9a9c928ef19f167c2eef6a91f30d/kasa_crypt-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:81d6b2e9f05a24bcfb6ddd3e38cea9aea5419701d312035fa48f1801d825627d", size = 81619, upload-time = "2025-10-04T18:54:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/34/b9/d9662f2b40c1b8eea166fcb7bdfbf5231d5ce3e24c6e6d761e8d518ede32/kasa_crypt-1.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d5790b603bcf9c483c8645688433b047bec2be8e31ebf8e182792d63a16829f", size = 84714, upload-time = "2025-10-04T18:54:10.97Z" }, + { url = "https://files.pythonhosted.org/packages/39/63/c1f6feaa4e4fe574b38d2ee694d8b810038b23430be01ad57a6a75d94f8d/kasa_crypt-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f541678b18845a9747e1e1475b328876409e1fb897a14f3d719213bb50f0946", size = 85506, upload-time = "2025-10-04T18:54:12.324Z" }, + { url = "https://files.pythonhosted.org/packages/71/6f/aa15d0efa7b96cec0306e80becc52b2a53259dcbfaec20dddfaa06d105e7/kasa_crypt-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0f4a9ad0f304616e02fdd73382b80a42a69c90ccd0e644b5a815ddc3b9f5621", size = 25720, upload-time = "2025-10-04T18:54:13.285Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/41f017338a161c9464cd067875846f496c98d85941484b315201aac8470c/kasa_crypt-1.1.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:1da8a9dc679d560b7ba6c8eb9d4bf1de8a5100bb5f8342489b2201eadd535a69", size = 27990, upload-time = "2025-10-04T18:43:34.319Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/5220ea64859496ee84377596a66cde34fc2503f64b88c83415e28506e6ef/kasa_crypt-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18318f9428819ecb870d0ee1199ab7155dc06a002290b0eec60e8277f0a0762c", size = 85397, upload-time = "2025-10-04T18:54:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/d5/31/e1ab889fdd6bb12df0acac5ae500a157abdca44ad48346e045bd9a36ab2b/kasa_crypt-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8cabb7e9dbd52de8ddc894516b6a4ed73d810d432625b0c814d674bb5cae2367", size = 26257, upload-time = "2025-10-04T18:54:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c3/9957c5ac10deeffbb391442f1a98e0b0770a18c7e08a0a32b3ede8bf121e/kasa_crypt-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c11c690b3b2fe9fc1591e1bc5050ae6ddf53ad9fa56723db283c490ffeaf8947", size = 85184, upload-time = "2025-10-04T18:54:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/42/a5/8a5ded18041dc9b9c710870f57ac7e02bb1f8160f6cc64c8eeb1ac389021/kasa_crypt-1.1.0-cp313-cp313-win32.whl", hash = "sha256:bcf88a522e097e170b878d48560a33b556601835034e7b2fcda6e5f0e32371c8", size = 80755, upload-time = "2025-10-04T18:54:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/94/c7/1272636de4c684461dfc8609a9e0bc0d2dc073afc2104925e3b1acd1933c/kasa_crypt-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f2a970f15d530a4484bd75a6bb9407eeddf9df2f78dd97b98aed9e72efa86fe", size = 82658, upload-time = "2025-10-04T18:54:18.801Z" }, + { url = "https://files.pythonhosted.org/packages/db/9c/8532cff823f87e6457c19e76d520c3352266e63ecd68b0710e12dc6f3d1e/kasa_crypt-1.1.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a92b5c14816d98fffded18856b6459ac99950cc86f364baa8a4005f501956fca", size = 80877, upload-time = "2025-10-04T18:54:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/37/7d/0d266e5209b365665ef980c0d7a0b207c66f09b0a35b06e168438abd3696/kasa_crypt-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7585f1dbeeb1e349e69c3d58d67cbcf11b53c37f145a538d0e0c5d6e1f1b3ace", size = 81771, upload-time = "2025-10-04T18:54:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/a4/03/639b0d49ead35519c132cf461c86e4883484c4c4af6d78f59afd107d9e3e/kasa_crypt-1.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b257ce5d9b71cad5218a86a4db07213fbd6611232fa2c4bc3fe3eed79b03de9", size = 84717, upload-time = "2025-10-04T18:54:21.844Z" }, + { url = "https://files.pythonhosted.org/packages/a3/be/8c23aa4ee6c48859dfe7920be7d47db44429e65544199668eaaca95c0c06/kasa_crypt-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:550fa2b4c5cb247387e6972f9fc0182eca82b524ee945c513553ec313e12d25e", size = 85700, upload-time = "2025-10-04T18:54:22.819Z" }, + { url = "https://files.pythonhosted.org/packages/ac/89/a67d66227a5c2ed529b2806d884be5272b69cbca25d20206e60f359c60b6/kasa_crypt-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9584a0154eaa4a7dff7aff5124d8c03c728b515426f220656293ea2d0d61b2a3", size = 25566, upload-time = "2025-10-04T18:54:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/d2/26/6c204f4b7f0a6eee441c5e0d0222dadf07f32050a1d7f8a83b0fb2bf8572/kasa_crypt-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5c3802d7499dca24fd746893c13d104feb28be3cabf729d958ebf6f2d0a944", size = 85594, upload-time = "2025-10-04T18:54:24.684Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/f7b45ad3eed7e46d186575c660c3310a5a28f2d9071cf83d50d2a37b57b5/kasa_crypt-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6fe56c4dc97ce50508a28d412083c5f8eb23350184c958905de9ad6561a90fc9", size = 25991, upload-time = "2025-10-04T18:54:25.662Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/679e2fdba6cef9e062ef3be33a254b2c28ed3f386bf8c6d46dff0d2fd9bf/kasa_crypt-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3b2d2685ae745d246df1aebdf24dfca1fe38c0966d34c8c149a5c8b3c5b6276a", size = 85215, upload-time = "2025-10-04T18:54:26.596Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/0268d9709e75b73ba6422d66be2a246be77bed3f607625b814a91cc979aa/kasa_crypt-1.1.0-cp314-cp314-win32.whl", hash = "sha256:b489066ace258aa163742a7250738bb1f5ed2cf8be1a95c4b1dc151ca6cd387a", size = 81362, upload-time = "2025-10-04T18:54:27.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4c/681d17df32cb349ddf729d85874a6b2f16b0054965299f983d03f236c922/kasa_crypt-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:834cacf6167df3d0a026924502762b52e07f700614ed99cd1866efce0f405176", size = 83313, upload-time = "2025-10-04T18:54:28.545Z" }, + { url = "https://files.pythonhosted.org/packages/3f/01/38a94de2621e3eed5aa44af34a3b02fbac2404ffa892b1583468970d6fca/kasa_crypt-1.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5aa6c784378dfd25dd3bb8093737ea96038ad57272043e4e6abd2ab96522c3ca", size = 97559, upload-time = "2025-10-04T18:54:29.543Z" }, + { url = "https://files.pythonhosted.org/packages/34/66/cd3aa8cbe4468566c1d351578f29716534c095f7ba6c251caaaa25d94109/kasa_crypt-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:752afbc6cce537bd305d6e8d33cabe726942d571a2b3b6548b3931d469c3223f", size = 99193, upload-time = "2025-10-04T18:54:30.533Z" }, + { url = "https://files.pythonhosted.org/packages/a4/51/2676e5b2349afce9430b2142daf876085c2de5e4f4798b222e911d8ef6b7/kasa_crypt-1.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ed8ba1f4e83e264e18cba8f08767f80640ec80389e3b3a289690e2e4dfe2ff77", size = 105633, upload-time = "2025-10-04T18:54:31.577Z" }, + { url = "https://files.pythonhosted.org/packages/c2/27/450f4776c5d21e8f65f28f8c5cea6d93b00728ab3a9ff724aea4579abd88/kasa_crypt-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd5a182ae490c30ddf524e07e53ca0ae670cf15072f27f32c992c1e3b95ca199", size = 108130, upload-time = "2025-10-04T18:54:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/78d7796d3ff169565819f3d9309f9c647b046ad46706d5110e83f51f8358/kasa_crypt-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8f1ff08c3d1576d4136b7f3cd79b18aa33d891edb3009264aef538c8f36208a", size = 100390, upload-time = "2025-10-04T18:54:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/ecc2033f1e58641403026b0fafeebd713ff7fcb84b735be4e59a4546836a/kasa_crypt-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1410f082f9ae06ae386ed1f3f7d05482f3cd8da29d66033e921c09b8243fa8a5", size = 108055, upload-time = "2025-10-04T18:54:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/08/f0/1a388d81b9b6366844f6d0db9f9428f4ecfe63aa593edacefabbb95c8b34/kasa_crypt-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:65326d6edabf74954ff4608b92a5024a444675e4872a0d380e28d5ab75dde55f", size = 101582, upload-time = "2025-10-04T18:54:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/e0b91c354a1bb2c7fd73ae049d9181481e62391c6e2169c2395f9baec86b/kasa_crypt-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e93f209c84f69542af911f251e3bd0dafdbeb3797277dd81935e31635201d6e8", size = 106649, upload-time = "2025-10-04T18:54:37.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/d7e32136710bfd087444fa1dee0926919a5f78c34490c964185b85a54dda/kasa_crypt-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:468feb7c22114af0378af2a0807a6bf34c2edcfd60b917cdce4ce071d9423108", size = 99608, upload-time = "2025-10-04T18:54:38.267Z" }, + { url = "https://files.pythonhosted.org/packages/34/27/4d098f82b5d7ffe42c97184ea9e225087f1e8a256c67b0d18516e6faffbf/kasa_crypt-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4004a4bddae2a00c2203a3808e728c6459bdc77e63dbc67ec357c0b1146bd75a", size = 103782, upload-time = "2025-10-04T18:54:39.656Z" }, +] + +[[package]] +name = "librt" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, + { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, + { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, + { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, + { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, ] [[package]] @@ -558,186 +892,297 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mashumaro" -version = "3.15" +version = "3.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/c1/7b687c8b993202e2eb49e559b25599d8e85f1b6d92ce676c8801226b8bdf/mashumaro-3.15.tar.gz", hash = "sha256:32a2a38a1e942a07f2cbf9c3061cb2a247714ee53e36a5958548b66bd116d0a9", size = 188646 } +sdist = { url = "https://files.pythonhosted.org/packages/d1/e3/a06dcd2e6df094c5e294721926f1f76da30f50823e2ac233a9198e804891/mashumaro-3.22.tar.gz", hash = "sha256:64538cc365204402a060ebde683a86505b5a4344acf6870d79021e9fbfe57360", size = 197845, upload-time = "2026-05-26T14:39:21.859Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/59/595eabaa779c87a72d65864351e0fdd2359d7d73967d5ed9f2f0c6186fa3/mashumaro-3.15-py3-none-any.whl", hash = "sha256:cdd45ef5a4d09860846a3ee37a4c2f5f4bc70eb158caa55648c4c99451ca6c4c", size = 93761 }, + { url = "https://files.pythonhosted.org/packages/9b/1c/92fd926c2e7763535454683250dbdd8d10aa2f2c62f58d6abbcce4d8b3fc/mashumaro-3.22-py3-none-any.whl", hash = "sha256:17dc4d7294c33ef380a8b929dda0608577aa2141988c00a0c4932310108fe71d", size = 95916, upload-time = "2026-05-26T14:39:20.233Z" }, ] [[package]] name = "mdit-py-plugins" -version = "0.4.2" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "multidict" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 }, - { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 }, - { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 }, - { url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067 }, - { url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507 }, - { url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905 }, - { url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004 }, - { url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308 }, - { url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608 }, - { url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029 }, - { url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594 }, - { url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556 }, - { url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993 }, - { url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405 }, - { url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795 }, - { url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713 }, - { url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516 }, - { url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557 }, - { url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170 }, - { url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836 }, - { url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475 }, - { url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049 }, - { url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370 }, - { url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178 }, - { url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567 }, - { url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822 }, - { url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656 }, - { url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360 }, - { url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382 }, - { url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529 }, - { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 }, - { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 }, - { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 }, - { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 }, - { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 }, - { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 }, - { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 }, - { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 }, - { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 }, - { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 }, - { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 }, - { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 }, - { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 }, - { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 }, - { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 }, - { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "mypy" -version = "1.13.0" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, - { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, - { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, - { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, - { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, - { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, - { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, - { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, - { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, - { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, - { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, - { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, - { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, - { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, - { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, - { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "myst-parser" -version = "4.0.0" +version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -747,102 +1192,136 @@ dependencies = [ { name = "pyyaml" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/55/6d1741a1780e5e65038b74bce6689da15f620261c490c3511eb4c12bac4b/myst_parser-4.0.0.tar.gz", hash = "sha256:851c9dfb44e36e56d15d05e72f02b80da21a9e0d07cba96baf5e2d476bb91531", size = 93858 } +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/b4/b036f8fdb667587bb37df29dc6644681dd78b7a2a6321a34684b79412b28/myst_parser-4.0.0-py3-none-any.whl", hash = "sha256:b9317997552424448c6096c2558872fdb6f81d3ecb3a40ce84a7518798f3f28d", size = 84563 }, + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, ] [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "orjson" -version = "3.10.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/04/bb9f72987e7f62fb591d6c880c0caaa16238e4e530cbc3bdc84a7372d75f/orjson-3.10.12.tar.gz", hash = "sha256:0a78bbda3aea0f9f079057ee1ee8a1ecf790d4f1af88dd67493c6b8ee52506ff", size = 5438647 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/48/7c3cd094488f5a3bc58488555244609a8c4d105bc02f2b77e509debf0450/orjson-3.10.12-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a734c62efa42e7df94926d70fe7d37621c783dea9f707a98cdea796964d4cf74", size = 248687 }, - { url = "https://files.pythonhosted.org/packages/ff/90/e55f0e25c7fdd1f82551fe787f85df6f378170caca863c04c810cd8f2730/orjson-3.10.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:750f8b27259d3409eda8350c2919a58b0cfcd2054ddc1bd317a643afc646ef23", size = 136953 }, - { url = "https://files.pythonhosted.org/packages/2a/b3/109c020cf7fee747d400de53b43b183ca9d3ebda3906ad0b858eb5479718/orjson-3.10.12-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb52c22bfffe2857e7aa13b4622afd0dd9d16ea7cc65fd2bf318d3223b1b6252", size = 149090 }, - { url = "https://files.pythonhosted.org/packages/96/d4/35c0275dc1350707d182a1b5da16d1184b9439848060af541285407f18f9/orjson-3.10.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:440d9a337ac8c199ff8251e100c62e9488924c92852362cd27af0e67308c16ef", size = 140480 }, - { url = "https://files.pythonhosted.org/packages/3b/79/f863ff460c291ad2d882cc3b580cc444bd4ec60c9df55f6901e6c9a3f519/orjson-3.10.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e15c06491c69997dfa067369baab3bf094ecb74be9912bdc4339972323f252", size = 156564 }, - { url = "https://files.pythonhosted.org/packages/98/7e/8d5835449ddd873424ee7b1c4ba73a0369c1055750990d824081652874d6/orjson-3.10.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:362d204ad4b0b8724cf370d0cd917bb2dc913c394030da748a3bb632445ce7c4", size = 131279 }, - { url = "https://files.pythonhosted.org/packages/46/f5/d34595b6d7f4f984c6fef289269a7f98abcdc2445ebdf90e9273487dda6b/orjson-3.10.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b57cbb4031153db37b41622eac67329c7810e5f480fda4cfd30542186f006ae", size = 139764 }, - { url = "https://files.pythonhosted.org/packages/b3/5b/ee6e9ddeab54a7b7806768151c2090a2d36025bc346a944f51cf172ef7f7/orjson-3.10.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:165c89b53ef03ce0d7c59ca5c82fa65fe13ddf52eeb22e859e58c237d4e33b9b", size = 131915 }, - { url = "https://files.pythonhosted.org/packages/c4/45/febee5951aef6db5cd8cdb260548101d7ece0ca9d4ddadadf1766306b7a4/orjson-3.10.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5dee91b8dfd54557c1a1596eb90bcd47dbcd26b0baaed919e6861f076583e9da", size = 415783 }, - { url = "https://files.pythonhosted.org/packages/27/a5/5a8569e49f3a6c093bee954a3de95062a231196f59e59df13a48e2420081/orjson-3.10.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a4e1cfb72de6f905bdff061172adfb3caf7a4578ebf481d8f0530879476c07", size = 142387 }, - { url = "https://files.pythonhosted.org/packages/6e/05/02550fb38c5bf758f3994f55401233a2ef304e175f473f2ac6dbf464cc8b/orjson-3.10.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:038d42c7bc0606443459b8fe2d1f121db474c49067d8d14c6a075bbea8bf14dd", size = 130664 }, - { url = "https://files.pythonhosted.org/packages/8c/f4/ba31019d0646ce51f7ac75af6dabf98fd89dbf8ad87a9086da34710738e7/orjson-3.10.12-cp311-none-win32.whl", hash = "sha256:03b553c02ab39bed249bedd4abe37b2118324d1674e639b33fab3d1dafdf4d79", size = 143623 }, - { url = "https://files.pythonhosted.org/packages/83/fe/babf08842b989acf4c46103fefbd7301f026423fab47e6f3ba07b54d7837/orjson-3.10.12-cp311-none-win_amd64.whl", hash = "sha256:8b8713b9e46a45b2af6b96f559bfb13b1e02006f4242c156cbadef27800a55a8", size = 135074 }, - { url = "https://files.pythonhosted.org/packages/a1/2f/989adcafad49afb535da56b95d8f87d82e748548b2a86003ac129314079c/orjson-3.10.12-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53206d72eb656ca5ac7d3a7141e83c5bbd3ac30d5eccfe019409177a57634b0d", size = 248678 }, - { url = "https://files.pythonhosted.org/packages/69/b9/8c075e21a50c387649db262b618ebb7e4d40f4197b949c146fc225dd23da/orjson-3.10.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8010afc2150d417ebda810e8df08dd3f544e0dd2acab5370cfa6bcc0662f8f", size = 136763 }, - { url = "https://files.pythonhosted.org/packages/87/d3/78edf10b4ab14c19f6d918cf46a145818f4aca2b5a1773c894c5490d3a4c/orjson-3.10.12-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed459b46012ae950dd2e17150e838ab08215421487371fa79d0eced8d1461d70", size = 149137 }, - { url = "https://files.pythonhosted.org/packages/16/81/5db8852bdf990a0ddc997fa8f16b80895b8cc77c0fe3701569ed2b4b9e78/orjson-3.10.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dcb9673f108a93c1b52bfc51b0af422c2d08d4fc710ce9c839faad25020bb69", size = 140567 }, - { url = "https://files.pythonhosted.org/packages/fa/a6/9ce1e3e3db918512efadad489630c25841eb148513d21dab96f6b4157fa1/orjson-3.10.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22a51ae77680c5c4652ebc63a83d5255ac7d65582891d9424b566fb3b5375ee9", size = 156620 }, - { url = "https://files.pythonhosted.org/packages/47/d4/05133d6bea24e292d2f7628b1e19986554f7d97b6412b3e51d812e38db2d/orjson-3.10.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910fdf2ac0637b9a77d1aad65f803bac414f0b06f720073438a7bd8906298192", size = 131555 }, - { url = "https://files.pythonhosted.org/packages/b9/7a/b3fbffda8743135c7811e95dc2ab7cdbc5f04999b83c2957d046f1b3fac9/orjson-3.10.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24ce85f7100160936bc2116c09d1a8492639418633119a2224114f67f63a4559", size = 139743 }, - { url = "https://files.pythonhosted.org/packages/b5/13/95bbcc9a6584aa083da5ce5004ce3d59ea362a542a0b0938d884fd8790b6/orjson-3.10.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a76ba5fc8dd9c913640292df27bff80a685bed3a3c990d59aa6ce24c352f8fc", size = 131733 }, - { url = "https://files.pythonhosted.org/packages/e8/29/dddbb2ea6e7af426fcc3da65a370618a88141de75c6603313d70768d1df1/orjson-3.10.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ff70ef093895fd53f4055ca75f93f047e088d1430888ca1229393a7c0521100f", size = 415788 }, - { url = "https://files.pythonhosted.org/packages/53/df/4aea59324ac539975919b4705ee086aced38e351a6eb3eea0f5071dd5661/orjson-3.10.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4244b7018b5753ecd10a6d324ec1f347da130c953a9c88432c7fbc8875d13be", size = 142347 }, - { url = "https://files.pythonhosted.org/packages/55/55/a52d83d7c49f8ff44e0daab10554490447d6c658771569e1c662aa7057fe/orjson-3.10.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16135ccca03445f37921fa4b585cff9a58aa8d81ebcb27622e69bfadd220b32c", size = 130829 }, - { url = "https://files.pythonhosted.org/packages/a1/8b/b1beb1624dd4adf7d72e2d9b73c4b529e7851c0c754f17858ea13e368b33/orjson-3.10.12-cp312-none-win32.whl", hash = "sha256:2d879c81172d583e34153d524fcba5d4adafbab8349a7b9f16ae511c2cee8708", size = 143659 }, - { url = "https://files.pythonhosted.org/packages/13/91/634c9cd0bfc6a857fc8fab9bf1a1bd9f7f3345e0d6ca5c3d4569ceb6dcfa/orjson-3.10.12-cp312-none-win_amd64.whl", hash = "sha256:fc23f691fa0f5c140576b8c365bc942d577d861a9ee1142e4db468e4e17094fb", size = 135221 }, - { url = "https://files.pythonhosted.org/packages/1b/bb/3f560735f46fa6f875a9d7c4c2171a58cfb19f56a633d5ad5037a924f35f/orjson-3.10.12-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:47962841b2a8aa9a258b377f5188db31ba49af47d4003a32f55d6f8b19006543", size = 248662 }, - { url = "https://files.pythonhosted.org/packages/a3/df/54817902350636cc9270db20486442ab0e4db33b38555300a1159b439d16/orjson-3.10.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6334730e2532e77b6054e87ca84f3072bee308a45a452ea0bffbbbc40a67e296", size = 126055 }, - { url = "https://files.pythonhosted.org/packages/2e/77/55835914894e00332601a74540840f7665e81f20b3e2b9a97614af8565ed/orjson-3.10.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:accfe93f42713c899fdac2747e8d0d5c659592df2792888c6c5f829472e4f85e", size = 131507 }, - { url = "https://files.pythonhosted.org/packages/33/9e/b91288361898e3158062a876b5013c519a5d13e692ac7686e3486c4133ab/orjson-3.10.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7974c490c014c48810d1dede6c754c3cc46598da758c25ca3b4001ac45b703f", size = 131686 }, - { url = "https://files.pythonhosted.org/packages/b2/15/08ce117d60a4d2d3fd24e6b21db463139a658e9f52d22c9c30af279b4187/orjson-3.10.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3f250ce7727b0b2682f834a3facff88e310f52f07a5dcfd852d99637d386e79e", size = 415710 }, - { url = "https://files.pythonhosted.org/packages/71/af/c09da5ed58f9c002cf83adff7a4cdf3e6cee742aa9723395f8dcdb397233/orjson-3.10.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f31422ff9486ae484f10ffc51b5ab2a60359e92d0716fcce1b3593d7bb8a9af6", size = 142305 }, - { url = "https://files.pythonhosted.org/packages/17/d1/8612038d44f33fae231e9ba480d273bac2b0383ce9e77cb06bede1224ae3/orjson-3.10.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f29c5d282bb2d577c2a6bbde88d8fdcc4919c593f806aac50133f01b733846e", size = 130815 }, - { url = "https://files.pythonhosted.org/packages/67/2c/d5f87834be3591555cfaf9aecdf28f480a6f0b4afeaac53bad534bf9518f/orjson-3.10.12-cp313-none-win32.whl", hash = "sha256:f45653775f38f63dc0e6cd4f14323984c3149c05d6007b58cb154dd080ddc0dc", size = 143664 }, - { url = "https://files.pythonhosted.org/packages/6a/05/7d768fa3ca23c9b3e1e09117abeded1501119f1d8de0ab722938c91ab25d/orjson-3.10.12-cp313-none-win_amd64.whl", hash = "sha256:229994d0c376d5bdc91d92b3c9e6be2f1fbabd4cc1b59daae1443a46ee5e9825", size = 134944 }, +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] name = "packaging" -version = "24.2" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "parso" -version = "0.8.4" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pre-commit" -version = "4.0.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -851,83 +1330,137 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "prompt-toolkit" -version = "3.0.48" +version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "propcache" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/1c/71eec730e12aec6511e702ad0cd73c2872eccb7cad39de8ba3ba9de693ef/propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", size = 80811 }, - { url = "https://files.pythonhosted.org/packages/89/c3/7e94009f9a4934c48a371632197406a8860b9f08e3f7f7d922ab69e57a41/propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", size = 46365 }, - { url = "https://files.pythonhosted.org/packages/c0/1d/c700d16d1d6903aeab28372fe9999762f074b80b96a0ccc953175b858743/propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", size = 45602 }, - { url = "https://files.pythonhosted.org/packages/2e/5e/4a3e96380805bf742712e39a4534689f4cddf5fa2d3a93f22e9fd8001b23/propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", size = 236161 }, - { url = "https://files.pythonhosted.org/packages/a5/85/90132481183d1436dff6e29f4fa81b891afb6cb89a7306f32ac500a25932/propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", size = 244938 }, - { url = "https://files.pythonhosted.org/packages/4a/89/c893533cb45c79c970834274e2d0f6d64383ec740be631b6a0a1d2b4ddc0/propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", size = 243576 }, - { url = "https://files.pythonhosted.org/packages/8c/56/98c2054c8526331a05f205bf45cbb2cda4e58e56df70e76d6a509e5d6ec6/propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", size = 236011 }, - { url = "https://files.pythonhosted.org/packages/2d/0c/8b8b9f8a6e1abd869c0fa79b907228e7abb966919047d294ef5df0d136cf/propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504", size = 224834 }, - { url = "https://files.pythonhosted.org/packages/18/bb/397d05a7298b7711b90e13108db697732325cafdcd8484c894885c1bf109/propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", size = 224946 }, - { url = "https://files.pythonhosted.org/packages/25/19/4fc08dac19297ac58135c03770b42377be211622fd0147f015f78d47cd31/propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", size = 217280 }, - { url = "https://files.pythonhosted.org/packages/7e/76/c79276a43df2096ce2aba07ce47576832b1174c0c480fe6b04bd70120e59/propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", size = 220088 }, - { url = "https://files.pythonhosted.org/packages/c3/9a/8a8cf428a91b1336b883f09c8b884e1734c87f724d74b917129a24fe2093/propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", size = 233008 }, - { url = "https://files.pythonhosted.org/packages/25/7b/768a8969abd447d5f0f3333df85c6a5d94982a1bc9a89c53c154bf7a8b11/propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", size = 237719 }, - { url = "https://files.pythonhosted.org/packages/ed/0d/e5d68ccc7976ef8b57d80613ac07bbaf0614d43f4750cf953f0168ef114f/propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", size = 227729 }, - { url = "https://files.pythonhosted.org/packages/05/64/17eb2796e2d1c3d0c431dc5f40078d7282f4645af0bb4da9097fbb628c6c/propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", size = 40473 }, - { url = "https://files.pythonhosted.org/packages/83/c5/e89fc428ccdc897ade08cd7605f174c69390147526627a7650fb883e0cd0/propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", size = 44921 }, - { url = "https://files.pythonhosted.org/packages/7c/46/a41ca1097769fc548fc9216ec4c1471b772cc39720eb47ed7e38ef0006a9/propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", size = 80800 }, - { url = "https://files.pythonhosted.org/packages/75/4f/93df46aab9cc473498ff56be39b5f6ee1e33529223d7a4d8c0a6101a9ba2/propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", size = 46443 }, - { url = "https://files.pythonhosted.org/packages/0b/17/308acc6aee65d0f9a8375e36c4807ac6605d1f38074b1581bd4042b9fb37/propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", size = 45676 }, - { url = "https://files.pythonhosted.org/packages/65/44/626599d2854d6c1d4530b9a05e7ff2ee22b790358334b475ed7c89f7d625/propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", size = 246191 }, - { url = "https://files.pythonhosted.org/packages/f2/df/5d996d7cb18df076debae7d76ac3da085c0575a9f2be6b1f707fe227b54c/propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", size = 251791 }, - { url = "https://files.pythonhosted.org/packages/2e/6d/9f91e5dde8b1f662f6dd4dff36098ed22a1ef4e08e1316f05f4758f1576c/propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", size = 253434 }, - { url = "https://files.pythonhosted.org/packages/3c/e9/1b54b7e26f50b3e0497cd13d3483d781d284452c2c50dd2a615a92a087a3/propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", size = 248150 }, - { url = "https://files.pythonhosted.org/packages/a7/ef/a35bf191c8038fe3ce9a414b907371c81d102384eda5dbafe6f4dce0cf9b/propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", size = 233568 }, - { url = "https://files.pythonhosted.org/packages/97/d9/d00bb9277a9165a5e6d60f2142cd1a38a750045c9c12e47ae087f686d781/propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", size = 229874 }, - { url = "https://files.pythonhosted.org/packages/8e/78/c123cf22469bdc4b18efb78893e69c70a8b16de88e6160b69ca6bdd88b5d/propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", size = 225857 }, - { url = "https://files.pythonhosted.org/packages/31/1b/fd6b2f1f36d028820d35475be78859d8c89c8f091ad30e377ac49fd66359/propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", size = 227604 }, - { url = "https://files.pythonhosted.org/packages/99/36/b07be976edf77a07233ba712e53262937625af02154353171716894a86a6/propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", size = 238430 }, - { url = "https://files.pythonhosted.org/packages/0d/64/5822f496c9010e3966e934a011ac08cac8734561842bc7c1f65586e0683c/propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", size = 244814 }, - { url = "https://files.pythonhosted.org/packages/fd/bd/8657918a35d50b18a9e4d78a5df7b6c82a637a311ab20851eef4326305c1/propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", size = 235922 }, - { url = "https://files.pythonhosted.org/packages/a8/6f/ec0095e1647b4727db945213a9f395b1103c442ef65e54c62e92a72a3f75/propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", size = 40177 }, - { url = "https://files.pythonhosted.org/packages/20/a2/bd0896fdc4f4c1db46d9bc361c8c79a9bf08ccc08ba054a98e38e7ba1557/propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", size = 44446 }, - { url = "https://files.pythonhosted.org/packages/a8/a7/5f37b69197d4f558bfef5b4bceaff7c43cc9b51adf5bd75e9081d7ea80e4/propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", size = 78120 }, - { url = "https://files.pythonhosted.org/packages/c8/cd/48ab2b30a6b353ecb95a244915f85756d74f815862eb2ecc7a518d565b48/propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", size = 45127 }, - { url = "https://files.pythonhosted.org/packages/a5/ba/0a1ef94a3412aab057bd996ed5f0ac7458be5bf469e85c70fa9ceb43290b/propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", size = 44419 }, - { url = "https://files.pythonhosted.org/packages/b4/6c/ca70bee4f22fa99eacd04f4d2f1699be9d13538ccf22b3169a61c60a27fa/propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", size = 229611 }, - { url = "https://files.pythonhosted.org/packages/19/70/47b872a263e8511ca33718d96a10c17d3c853aefadeb86dc26e8421184b9/propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", size = 234005 }, - { url = "https://files.pythonhosted.org/packages/4f/be/3b0ab8c84a22e4a3224719099c1229ddfdd8a6a1558cf75cb55ee1e35c25/propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", size = 237270 }, - { url = "https://files.pythonhosted.org/packages/04/d8/f071bb000d4b8f851d312c3c75701e586b3f643fe14a2e3409b1b9ab3936/propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", size = 231877 }, - { url = "https://files.pythonhosted.org/packages/93/e7/57a035a1359e542bbb0a7df95aad6b9871ebee6dce2840cb157a415bd1f3/propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", size = 217848 }, - { url = "https://files.pythonhosted.org/packages/f0/93/d1dea40f112ec183398fb6c42fde340edd7bab202411c4aa1a8289f461b6/propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", size = 216987 }, - { url = "https://files.pythonhosted.org/packages/62/4c/877340871251145d3522c2b5d25c16a1690ad655fbab7bb9ece6b117e39f/propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", size = 212451 }, - { url = "https://files.pythonhosted.org/packages/7c/bb/a91b72efeeb42906ef58ccf0cdb87947b54d7475fee3c93425d732f16a61/propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", size = 212879 }, - { url = "https://files.pythonhosted.org/packages/9b/7f/ee7fea8faac57b3ec5d91ff47470c6c5d40d7f15d0b1fccac806348fa59e/propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", size = 222288 }, - { url = "https://files.pythonhosted.org/packages/ff/d7/acd67901c43d2e6b20a7a973d9d5fd543c6e277af29b1eb0e1f7bd7ca7d2/propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", size = 228257 }, - { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, - { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, - { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, - { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "ptpython" -version = "3.0.29" +version = "3.0.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -935,143 +1468,145 @@ dependencies = [ { name = "prompt-toolkit" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/61/352792c9f47de98a910526ff8a684466a6217e53ffa6627b3781960e4f0d/ptpython-3.0.29.tar.gz", hash = "sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e", size = 72622 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/8c/7e904ceeb512b4530c7ca1d918d3565d694a1fa7df337cdfc36a16347d68/ptpython-3.0.32.tar.gz", hash = "sha256:11651778236de95c582b42737294e50a66ba4a21fa01c0090ea70815af478fe0", size = 74080, upload-time = "2025-11-20T21:20:48.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/39/c6fd4dd531e067b6a01624126cff0b3ddc6569e22f83e48d8418ffa9e3be/ptpython-3.0.29-py2.py3-none-any.whl", hash = "sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402", size = 67057 }, + { url = "https://files.pythonhosted.org/packages/4c/ac/0e35e5d7afd47ab0e2c71293ed2ad18df91a2a4a008c0ff59c2f22def377/ptpython-3.0.32-py3-none-any.whl", hash = "sha256:16435d323e5fc0a685d5f4dc5bb4494fb68ac68736689cd1247e1eda9369b616", size = 68099, upload-time = "2025-11-20T21:20:46.634Z" }, ] [[package]] name = "pycparser" -version = "2.22" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pygments" -version = "2.18.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" -version = "8.3.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "pytest-cov" -version = "6.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-freezer" -version = "0.4.8" +version = "0.4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "freezegun" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/fa/a93d40dd50f712c276a5a15f9c075bee932cc4d28c376e60b4a35904976d/pytest_freezer-0.4.8.tar.gz", hash = "sha256:8ee2f724b3ff3540523fa355958a22e6f4c1c819928b78a7a183ae4248ce6ee6", size = 3212 } +sdist = { url = "https://files.pythonhosted.org/packages/81/f0/98dcbc5324064360b19850b14c84cea9ca50785d921741dbfc442346e925/pytest_freezer-0.4.9.tar.gz", hash = "sha256:21bf16bc9cc46bf98f94382c4b5c3c389be7056ff0be33029111ae11b3f1c82a", size = 3177, upload-time = "2024-12-12T08:53:08.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/4e/ba488639516a341810aeaeb4b32b70abb0923e53f7c4d14d673dc114d35a/pytest_freezer-0.4.8-py3-none-any.whl", hash = "sha256:644ce7ddb8ba52b92a1df0a80a699bad2b93514c55cf92e9f2517b68ebe74814", size = 3228 }, + { url = "https://files.pythonhosted.org/packages/c1/e9/30252bc05bcf67200a17f4f0b4cc7598f0a68df4fa9fa356193aa899f145/pytest_freezer-0.4.9-py3-none-any.whl", hash = "sha256:8b6c50523b7d4aec4590b52bfa5ff766d772ce506e2bf4846c88041ea9ccae59", size = 3192, upload-time = "2024-12-12T08:53:07.641Z" }, ] [[package]] name = "pytest-mock" -version = "3.14.0" +version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] [[package]] name = "pytest-socket" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389 } +sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754 }, + { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" }, ] [[package]] name = "pytest-sugar" -version = "1.0.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, { name = "pytest" }, { name = "termcolor" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, ] [[package]] name = "pytest-timeout" -version = "2.3.1" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/0d/04719abc7a4bdb3a7a1f968f24b0f5253d698c9cc94975330e9d3145befb/pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9", size = 17697 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/27/14af9ef8321f5edc7527e47def2a21d8118c6f329a9342cc61387a0c0599/pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e", size = 14148 }, + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] [[package]] name = "pytest-xdist" -version = "3.6.1" +version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] @@ -1081,31 +1616,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, ] [[package]] name = "python-kasa" -version = "0.8.1" +version = "0.10.2" source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "asyncclick" }, { name = "cryptography" }, { name = "mashumaro" }, - { name = "tzdata", marker = "platform_system == 'Windows'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] [package.optional-dependencies] -docs = [ - { name = "docutils" }, - { name = "myst-parser" }, - { name = "sphinx" }, - { name = "sphinx-rtd-theme" }, - { name = "sphinxcontrib-programoutput" }, -] shell = [ { name = "ptpython" }, { name = "rich" }, @@ -1132,27 +1673,31 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "toml" }, + { name = "uv" }, { name = "voluptuous" }, { name = "xdoctest" }, ] +docs = [ + { name = "docutils" }, + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-programoutput" }, +] [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3" }, { name = "asyncclick", specifier = ">=8.1.7" }, { name = "cryptography", specifier = ">=1.9" }, - { name = "docutils", marker = "extra == 'docs'", specifier = ">=0.17" }, { name = "kasa-crypt", marker = "extra == 'speedups'", specifier = ">=0.2.0" }, - { name = "mashumaro", specifier = ">=3.14" }, - { name = "myst-parser", marker = "extra == 'docs'" }, - { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" }, + { name = "mashumaro", specifier = ">=3.20" }, + { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.11.1" }, { name = "ptpython", marker = "extra == 'shell'" }, { name = "rich", marker = "extra == 'shell'" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.4.7" }, - { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, - { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, - { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, + { name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2024.2" }, ] +provides-extras = ["speedups", "shell"] [package.metadata.requires-dev] dev = [ @@ -1169,50 +1714,78 @@ dev = [ { name = "pytest-sugar" }, { name = "pytest-timeout", specifier = "~=2.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "ruff", specifier = "==0.7.4" }, + { name = "ruff", specifier = ">=0.9.0" }, { name = "toml" }, + { name = "uv", specifier = ">=0.11.26" }, { name = "voluptuous" }, { name = "xdoctest", specifier = ">=1.2.0" }, ] +docs = [ + { name = "docutils", specifier = ">=0.17" }, + { name = "myst-parser" }, + { name = "sphinx", specifier = ">=7.4.7" }, + { name = "sphinx-rtd-theme", specifier = "~=2.0" }, + { name = "sphinxcontrib-programoutput", specifier = "~=0.0" }, +] [[package]] name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "requests" -version = "2.32.3" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1220,74 +1793,65 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rich" -version = "13.9.4" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" -version = "0.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512 }, - { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436 }, - { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936 }, - { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353 }, - { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078 }, - { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823 }, - { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855 }, - { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580 }, - { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057 }, - { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137 }, - { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243 }, - { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187 }, - { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715 }, - { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912 }, - { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767 }, - { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534 }, - { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590 }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "snowballstemmer" -version = "2.2.0" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] [[package]] @@ -1312,9 +1876,9 @@ dependencies = [ { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624 }, + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, ] [[package]] @@ -1326,36 +1890,36 @@ dependencies = [ { name = "sphinx" }, { name = "sphinxcontrib-jquery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005, upload-time = "2023-11-28T04:14:03.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721 }, + { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721, upload-time = "2023-11-28T04:13:59.589Z" }, ] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, ] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, ] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] [[package]] @@ -1365,203 +1929,313 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331 } +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 }, + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, ] [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] [[package]] name = "sphinxcontrib-programoutput" -version = "0.17" +version = "0.20" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "docutils" }, { name = "sphinx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/fe/8a6d8763674b3d3814a6008a83eb8002b6da188710dd7f4654ec77b4a8ac/sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f", size = 24067 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/e5/0549d81b4144a1cc8464d368bd2d7a3482fd185399d9430c548978f13036/sphinxcontrib_programoutput-0.20.tar.gz", hash = "sha256:5c4282c1c7fc9b5a23febe16ae038b6392d7ce068d186ad4870ba22e74db0711", size = 28235, upload-time = "2026-06-16T15:55:52.243Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/ee/b7be4b3f45f4e36bfa6c444cd234098e0d09880379c67a43e6bb9ab99a86/sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84", size = 22131 }, + { url = "https://files.pythonhosted.org/packages/aa/02/bb155d4a570d7ceaf48e81f1412093ecf066437bfcb56f34c1407bf14f50/sphinxcontrib_programoutput-0.20-py3-none-any.whl", hash = "sha256:f473f9e7992879358efdeb9962fbec2103f6ba1cb6a738eb7f81dae554bc5ec7", size = 20722, upload-time = "2026-06-16T15:55:51.353Z" }, ] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, ] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] [[package]] name = "termcolor" -version = "2.5.0" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057 } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755 }, + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] [[package]] name = "toml" version = "0.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] [[package]] name = "tomli" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750 }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "tzdata" -version = "2024.2" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] name = "urllib3" -version = "2.2.3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uv" +version = "0.11.27" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/b8bb8bf2044561e3349ebe75be0d8abdeb19577886753b49200ec6caea0d/uv-0.11.27.tar.gz", hash = "sha256:3469204521869f0e6bdea17b02c1d86db2d0150820895653a6152cab206fb00b", size = 5978029, upload-time = "2026-07-06T21:00:29.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, + { url = "https://files.pythonhosted.org/packages/b2/fe/8a080fb603f276ceb2d960454b2b5998cc6e5c0c6d5dbbbeb48b141de4f2/uv-0.11.27-py3-none-linux_armv6l.whl", hash = "sha256:6cedad2185b16bd5da77096d4813159c9c3c572897537256eecdb4092941adc0", size = 25490659, upload-time = "2026-07-06T20:59:29.003Z" }, + { url = "https://files.pythonhosted.org/packages/d1/15/886a2ef7b00c1a4fcf706e7a6b946b3f7485a55f42d0aa9b5572caed58b6/uv-0.11.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42b273459864c67f3736ae2a63929df463975d885bbc1afd8f53f7e0fb1f2269", size = 24498434, upload-time = "2026-07-06T20:59:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6e/45335717c4372bf955c87bc05c6462dd4c0c6087ed12c3991b14cbba77cd/uv-0.11.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b316464a1028a43478b74424b4b78925c463758baec93cb754fb2392ba175138", size = 23248727, upload-time = "2026-07-06T20:59:36.201Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fa/41dde1e350c5a277b797371faf96fb013c2c13ed18df6efc51ed8ec9bb5b/uv-0.11.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3ca478883d78270ceed57f229d1b480dbcadf5d79b2ce9624574807d6ec48554", size = 25208126, upload-time = "2026-07-06T20:59:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/bc6d4af24ab533db4c741cebade3a58b91fc0563059d39e838161dd210c8/uv-0.11.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:232dfb7549c8a4217ab1970dea0de731926b8ca21769ed89951ad8f8c3bce8b6", size = 25030974, upload-time = "2026-07-06T20:59:43.378Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0c/82e736930d3f78ca6c3286ef31627d7155e20191af88afc40ec29486f400/uv-0.11.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0cf7c7960587a231252cbe4d7a032b5a02c29cfab3fcc4e75a915b1c5cacee", size = 25015115, upload-time = "2026-07-06T20:59:46.694Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5b/512ebf3b24b708ea2adda4ec4d4bd23b2404d7a7ea3bd24d2b92210ab680/uv-0.11.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47b5863ef715ffe4c29e792c9884a4e8b8016b0628d3613ac84c696acc128a78", size = 26487914, upload-time = "2026-07-06T20:59:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/98/d5/3871185331266156fbd09ab0ad7ddc69ca759b3a28ece5488bdb64f152d9/uv-0.11.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5764155d2a578a41d2827f464d4ce5b7788632e732cb304e2025fb3d043b46f", size = 27393306, upload-time = "2026-07-06T20:59:53.852Z" }, + { url = "https://files.pythonhosted.org/packages/24/0f/9fe3cc9b8d498f2e426b3cda90d337b076d64f19c2d55cb0b7d091c4bfe9/uv-0.11.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d806e1067c8eb9f8ba5dbdca46484512d224070e8473442319d4c1fbc3246f4d", size = 26563495, upload-time = "2026-07-06T20:59:56.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/be/18393c15ac0c982fefa34944135ead3adc565da5023d973eff5081a95a62/uv-0.11.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb783b9db3868e51e8da1956aaa7e800262ad1af03dfc8b024f3b04ecb447c2", size = 26652374, upload-time = "2026-07-06T21:00:00.256Z" }, + { url = "https://files.pythonhosted.org/packages/b8/93/1bb8bee2b0bcc6e9655518ecaa9a792b1952dd99ae7366fe4f6c82754361/uv-0.11.27-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8e2b86d3dee515eeba6ea001d8b3eac20b204db07d39b04187c755a490a4fba6", size = 25341573, upload-time = "2026-07-06T21:00:03.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e0/fae0d1c972b25ac192ac807c1b1baf0f12070c40c8af2eb8e22310ea8846/uv-0.11.27-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:d325b8bd2665ce3ac58d33f977683cfb2cb8eb951effab06fad8518426bc0a90", size = 26051363, upload-time = "2026-07-06T21:00:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a2/4c0c103ea431f5d70cd33f10c754b5b1de9687fea982ca1a20a4eee0a4a7/uv-0.11.27-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d5c9036b558a323f254e3d16038183c22c55e0d06b921f1ed9706953311e52f", size = 26148896, upload-time = "2026-07-06T21:00:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b7/a514307a90ca9f1cb59efd0898958dea6c1092ad5e4e6b6f2eb4d55109db/uv-0.11.27-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4cc04e625e7bfac6975fd384d6598169cea0030995ff3b557950ec3077974a4d", size = 25701138, upload-time = "2026-07-06T21:00:13.772Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/c6cfa675d727a199cfce32f7a30350ce6e47f699c3cd7f0433a619c671d4/uv-0.11.27-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:109d8e145b390d0e14f2aefd9708a7522a4236d6bee20361a7558e77a14c0531", size = 26913232, upload-time = "2026-07-06T21:00:17.233Z" }, + { url = "https://files.pythonhosted.org/packages/a4/04/0070ecdea37baa9d38187abedc78eed9a52a2e6cb1d4f175c580640f3456/uv-0.11.27-py3-none-win32.whl", hash = "sha256:b330d2ab6af0f79396fb350a9fed8ed1aa92516310f7533de9a8dfebc4e147cd", size = 24374672, upload-time = "2026-07-06T21:00:20.399Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b9/f3df522d2e609fe511e5bee24b34c91ce281ce472594121fa1d5fa159d9d/uv-0.11.27-py3-none-win_amd64.whl", hash = "sha256:e20f25921ed4c46d32ac18a9b2e610c0384439fd367f647f2b68e0e1d90e9885", size = 27275452, upload-time = "2026-07-06T21:00:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/4ea9c3bbf95a21c627a3dfa359f5a3266e648cd103c5ade374a6e4265c5f/uv-0.11.27-py3-none-win_arm64.whl", hash = "sha256:a41da27667e95c0df939e99e77a4d9169fb895df0f6788b2ab65ed5efdc1395f", size = 25561477, upload-time = "2026-07-06T21:00:27.333Z" }, ] [[package]] name = "virtualenv" -version = "20.28.0" +version = "21.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 }, + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] [[package]] name = "voluptuous" -version = "0.15.2" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/af/a54ce0fb6f1d867e0b9f0efe5f082a691f51ccf705188fca67a3ecefd7f4/voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa", size = 51651 } +sdist = { url = "https://files.pythonhosted.org/packages/92/f4/0738e6849858deae22218be3bbb8207ba83a96e9d0ec7e8e8cd67b30e5ca/voluptuous-0.16.0.tar.gz", hash = "sha256:006535e22fed944aec17bef6e8725472476194743c87bd233e912eb463f8ff05", size = 54238, upload-time = "2025-12-18T23:18:46.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349 }, + { url = "https://files.pythonhosted.org/packages/d1/00/0e0da784245c93cf346150ab67634177bf277f93b7a162bb56c928c39c04/voluptuous-0.16.0-py3-none-any.whl", hash = "sha256:ee342095263e1b5afbd4d418cb5adc92810eebfd07696bb033a261210df33db4", size = 31931, upload-time = "2025-12-18T23:18:44.694Z" }, ] [[package]] name = "wcwidth" -version = "0.2.13" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] name = "xdoctest" -version = "1.2.0" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/a5/7f6dfdaf3a221e16ff79281d2a3c3e4b58989c92de8964a317feb1e6cbb5/xdoctest-1.2.0.tar.gz", hash = "sha256:d8cfca6d8991e488d33f756e600d35b9fdf5efd5c3a249d644efcbbbd2ed5863", size = 204804 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/df/1b5751e3546967a4884cff6ea743b1e457d3f7c94fc35913f149a85734fc/xdoctest-1.3.2.tar.gz", hash = "sha256:bf6078c4fc0d60aafd8753fccdc435c95f26a640508e606d188d96f48359f0aa", size = 209923, upload-time = "2026-03-27T01:13:25.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/b8/e4722f5e5f592a665cc8e55a334ea721c359f09574e6b987dc551a1e1f4c/xdoctest-1.2.0-py3-none-any.whl", hash = "sha256:0f1ecf5939a687bd1fc8deefbff1743c65419cce26dff908f8b84c93fbe486bc", size = 151194 }, + { url = "https://files.pythonhosted.org/packages/29/1d/6b9cf46122f2f07eab95c4af29ab5e29bd6674147a0b1e9a6d3929870af1/xdoctest-1.3.2-py3-none-any.whl", hash = "sha256:052118c8efb2b4cfb54485d328915b9e7b44da37c64b0998ca6aa21193dcb601", size = 146469, upload-time = "2026-03-27T01:13:23.417Z" }, ] [[package]] name = "yarl" -version = "1.18.0" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/53db4ecad4d54535aff3dfda1f00d6363d79455f62b11b8ca97b82746bd2/yarl-1.18.0.tar.gz", hash = "sha256:20d95535e7d833889982bfe7cc321b7f63bf8879788fee982c76ae2b24cfb715", size = 180098 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/45/6ad7135d1c4ad3a6a49e2c37dc78a1805a7871879c03c3495d64c9605d49/yarl-1.18.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e8c516dc4e1a51d86ac975b0350735007e554c962281c432eaa5822aa9765c", size = 141283 }, - { url = "https://files.pythonhosted.org/packages/45/6d/24b70ae33107d6eba303ed0ebfdf1164fe2219656e7594ca58628ebc0f1d/yarl-1.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6b4466714a73f5251d84b471475850954f1fa6acce4d3f404da1d55d644c34", size = 94082 }, - { url = "https://files.pythonhosted.org/packages/8a/0e/da720989be11b662ca847ace58f468b52310a9b03e52ac62c144755f9d75/yarl-1.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c893f8c1a6d48b25961e00922724732d00b39de8bb0b451307482dc87bddcd74", size = 92017 }, - { url = "https://files.pythonhosted.org/packages/f5/76/e5c91681fa54658943cb88673fb19b3355c3a8ae911a33a2621b6320990d/yarl-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13aaf2bdbc8c86ddce48626b15f4987f22e80d898818d735b20bd58f17292ee8", size = 340359 }, - { url = "https://files.pythonhosted.org/packages/cf/77/02cf72f09dea20980dea4ebe40dfb2c24916b864aec869a19f715428e0f0/yarl-1.18.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd21c0128e301851de51bc607b0a6da50e82dc34e9601f4b508d08cc89ee7929", size = 356336 }, - { url = "https://files.pythonhosted.org/packages/17/66/83a88d04e4fc243dd26109f3e3d6412f67819ab1142dadbce49706ef4df4/yarl-1.18.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205de377bd23365cd85562c9c6c33844050a93661640fda38e0567d2826b50df", size = 353730 }, - { url = "https://files.pythonhosted.org/packages/76/77/0b205a532d22756ab250ab21924d362f910a23d641c82faec1c4ad7f6077/yarl-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed69af4fe2a0949b1ea1d012bf065c77b4c7822bad4737f17807af2adb15a73c", size = 343882 }, - { url = "https://files.pythonhosted.org/packages/0b/47/2081ddce3da6096889c3947bdc21907d0fa15939909b10219254fe116841/yarl-1.18.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e1c18890091aa3cc8a77967943476b729dc2016f4cfe11e45d89b12519d4a93", size = 335873 }, - { url = "https://files.pythonhosted.org/packages/25/3c/437304394494e757ae927c9a81bacc4bcdf7351a1d4e811d95b02cb6dbae/yarl-1.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:91b8fb9427e33f83ca2ba9501221ffaac1ecf0407f758c4d2f283c523da185ee", size = 347725 }, - { url = "https://files.pythonhosted.org/packages/c6/fb/fa6c642bc052fbe6370ed5da765579650510157dea354fe9e8177c3bc34a/yarl-1.18.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:536a7a8a53b75b2e98ff96edb2dfb91a26b81c4fed82782035767db5a465be46", size = 346161 }, - { url = "https://files.pythonhosted.org/packages/b0/09/8c0cf68a0fcfe3b060c9e5857bb35735bc72a4cf4075043632c636d007e9/yarl-1.18.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a64619a9c47c25582190af38e9eb382279ad42e1f06034f14d794670796016c0", size = 349924 }, - { url = "https://files.pythonhosted.org/packages/bf/4b/1efe10fd51e2cedf53195d688fa270efbcd64a015c61d029d49c20bf0af7/yarl-1.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c73a6bbc97ba1b5a0c3c992ae93d721c395bdbb120492759b94cc1ac71bc6350", size = 361865 }, - { url = "https://files.pythonhosted.org/packages/0b/1b/2b5efd6df06bf938f7e154dee8e2ab22d148f3311a92bf4da642aaaf2fc5/yarl-1.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a173401d7821a2a81c7b47d4e7d5c4021375a1441af0c58611c1957445055056", size = 366030 }, - { url = "https://files.pythonhosted.org/packages/f8/db/786a5684f79278e62271038a698f56a51960f9e643be5d3eff82712f0b1c/yarl-1.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7520e799b1f84e095cce919bd6c23c9d49472deeef25fe1ef960b04cca51c3fc", size = 358902 }, - { url = "https://files.pythonhosted.org/packages/91/2f/437d0de062f1a3e3cb17573971b3832232443241133580c2ba3da5001d06/yarl-1.18.0-cp311-cp311-win32.whl", hash = "sha256:c4cb992d8090d5ae5f7afa6754d7211c578be0c45f54d3d94f7781c495d56716", size = 84138 }, - { url = "https://files.pythonhosted.org/packages/9d/85/035719a9266bce85ecde820aa3f8c46f3b18c3d7ba9ff51367b2fa4ae2a2/yarl-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:52c136f348605974c9b1c878addd6b7a60e3bf2245833e370862009b86fa4689", size = 90765 }, - { url = "https://files.pythonhosted.org/packages/23/36/c579b80a5c76c0d41c8e08baddb3e6940dfc20569db579a5691392c52afa/yarl-1.18.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ece25e2251c28bab737bdf0519c88189b3dd9492dc086a1d77336d940c28ced", size = 142376 }, - { url = "https://files.pythonhosted.org/packages/0c/5f/e247dc7c0607a0c505fea6c839721844bee55686dfb183c7d7b8ef8a9cb1/yarl-1.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:454902dc1830d935c90b5b53c863ba2a98dcde0fbaa31ca2ed1ad33b2a7171c6", size = 94692 }, - { url = "https://files.pythonhosted.org/packages/eb/e1/3081b578a6f21961711b9a1c49c2947abb3b0d0dd9537378fb06777ce8ee/yarl-1.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01be8688fc211dc237e628fcc209dda412d35de7642453059a0553747018d075", size = 92527 }, - { url = "https://files.pythonhosted.org/packages/2f/fa/d9e1b9fbafa4cc82cd3980b5314741b33c2fe16308d725449a23aed32021/yarl-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d26f1fa9fa2167bb238f6f4b20218eb4e88dd3ef21bb8f97439fa6b5313e30d", size = 332096 }, - { url = "https://files.pythonhosted.org/packages/93/b6/dd27165114317875838e216214fb86338dc63d2e50855a8f2a12de2a7fe5/yarl-1.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b234a4a9248a9f000b7a5dfe84b8cb6210ee5120ae70eb72a4dcbdb4c528f72f", size = 342047 }, - { url = "https://files.pythonhosted.org/packages/fc/9f/bad434b5279ae7a356844e14dc771c3d29eb928140bbc01621af811c8a27/yarl-1.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe94d1de77c4cd8caff1bd5480e22342dbd54c93929f5943495d9c1e8abe9f42", size = 341712 }, - { url = "https://files.pythonhosted.org/packages/9a/9f/63864f43d131ba8c8cdf1bde5dd3f02f0eff8a7c883a5d7fad32f204fda5/yarl-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4c90c5363c6b0a54188122b61edb919c2cd1119684999d08cd5e538813a28e", size = 336654 }, - { url = "https://files.pythonhosted.org/packages/20/30/b4542bbd9be73de155213207eec019f6fe6495885f7dd59aa1ff705a041b/yarl-1.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a98ecadc5a241c9ba06de08127ee4796e1009555efd791bac514207862b43d", size = 325484 }, - { url = "https://files.pythonhosted.org/packages/69/bc/e2a9808ec26989cf0d1b98fe7b3cc45c1c6506b5ea4fe43ece5991f28f34/yarl-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9106025c7f261f9f5144f9aa7681d43867eed06349a7cfb297a1bc804de2f0d1", size = 344213 }, - { url = "https://files.pythonhosted.org/packages/e2/17/0ee5a68886aca1a8071b0d24a1e1c0fd9970dead2ef2d5e26e027fb7ce88/yarl-1.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f275ede6199d0f1ed4ea5d55a7b7573ccd40d97aee7808559e1298fe6efc8dbd", size = 340517 }, - { url = "https://files.pythonhosted.org/packages/fd/db/1fe4ef38ee852bff5ec8f5367d718b3a7dac7520f344b8e50306f68a2940/yarl-1.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f7edeb1dcc7f50a2c8e08b9dc13a413903b7817e72273f00878cb70e766bdb3b", size = 346234 }, - { url = "https://files.pythonhosted.org/packages/b4/ee/5e5bccdb821eb9949ba66abb4d19e3299eee00282e37b42f65236120e892/yarl-1.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c083f6dd6951b86e484ebfc9c3524b49bcaa9c420cb4b2a78ef9f7a512bfcc85", size = 359625 }, - { url = "https://files.pythonhosted.org/packages/3f/43/95a64d9e7ab4aa1c34fc5ea0edb35b581bc6ad33fd960a8ae34c2040b319/yarl-1.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:80741ec5b471fbdfb997821b2842c59660a1c930ceb42f8a84ba8ca0f25a66aa", size = 364239 }, - { url = "https://files.pythonhosted.org/packages/40/19/09ce976c624c9d3cc898f0be5035ddef0c0759d85b2313321cfe77b69915/yarl-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1a3297b9cad594e1ff0c040d2881d7d3a74124a3c73e00c3c71526a1234a9f7", size = 357599 }, - { url = "https://files.pythonhosted.org/packages/7d/35/6f33fd29791af2ec161aebe8abe63e788c2b74a6c7e8f29c92e5f5e96849/yarl-1.18.0-cp312-cp312-win32.whl", hash = "sha256:cd6ab7d6776c186f544f893b45ee0c883542b35e8a493db74665d2e594d3ca75", size = 83832 }, - { url = "https://files.pythonhosted.org/packages/4e/8e/cdb40ef98597be107de67b11e2f1f23f911e0f1416b938885d17a338e304/yarl-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:039c299a0864d1f43c3e31570045635034ea7021db41bf4842693a72aca8df3a", size = 90132 }, - { url = "https://files.pythonhosted.org/packages/2b/77/2196b657c66f97adaef0244e9e015f30eac0df59c31ad540f79ce328feed/yarl-1.18.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6fb64dd45453225f57d82c4764818d7a205ee31ce193e9f0086e493916bd4f72", size = 140512 }, - { url = "https://files.pythonhosted.org/packages/0e/d8/2bb6e26fddba5c01bad284e4571178c651b97e8e06318efcaa16e07eb9fd/yarl-1.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3adaaf9c6b1b4fc258584f4443f24d775a2086aee82d1387e48a8b4f3d6aecf6", size = 93875 }, - { url = "https://files.pythonhosted.org/packages/54/e4/99fbb884dd9f814fb0037dc1783766bb9edcd57b32a76f3ec5ac5c5772d7/yarl-1.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da206d1ec78438a563c5429ab808a2b23ad7bc025c8adbf08540dde202be37d5", size = 91705 }, - { url = "https://files.pythonhosted.org/packages/3b/a2/5bd86eca9449e6b15d3b08005cf4e58e3da972240c2bee427b358c311549/yarl-1.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:576d258b21c1db4c6449b1c572c75d03f16a482eb380be8003682bdbe7db2f28", size = 333325 }, - { url = "https://files.pythonhosted.org/packages/94/50/a218da5f159cd985685bc72c500bb1a7fd2d60035d2339b8a9d9e1f99194/yarl-1.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e547c0a375c4bfcdd60eef82e7e0e8698bf84c239d715f5c1278a73050393", size = 344121 }, - { url = "https://files.pythonhosted.org/packages/a4/e3/830ae465811198b4b5ebecd674b5b3dca4d222af2155eb2144bfe190bbb8/yarl-1.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3818eabaefb90adeb5e0f62f047310079d426387991106d4fbf3519eec7d90a", size = 345163 }, - { url = "https://files.pythonhosted.org/packages/7a/74/05c4326877ca541eee77b1ef74b7ac8081343d3957af8f9291ca6eca6fec/yarl-1.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f72421246c21af6a92fbc8c13b6d4c5427dfd949049b937c3b731f2f9076bd", size = 339130 }, - { url = "https://files.pythonhosted.org/packages/29/42/842f35aa1dae25d132119ee92185e8c75d8b9b7c83346506bd31e9fa217f/yarl-1.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa7d37f2ada0f42e0723632993ed422f2a679af0e200874d9d861720a54f53e", size = 326418 }, - { url = "https://files.pythonhosted.org/packages/f9/ed/65c0514f2d1e8b92a61f564c914381d078766cab38b5fbde355b3b3af1fb/yarl-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42ba84e2ac26a3f252715f8ec17e6fdc0cbf95b9617c5367579fafcd7fba50eb", size = 345204 }, - { url = "https://files.pythonhosted.org/packages/23/31/351f64f0530c372fa01160f38330f44478e7bf3092f5ce2bfcb91605561d/yarl-1.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6a49ad0102c0f0ba839628d0bf45973c86ce7b590cdedf7540d5b1833ddc6f00", size = 341652 }, - { url = "https://files.pythonhosted.org/packages/49/aa/0c6e666c218d567727c1d040d01575685e7f9b18052fd68a59c9f61fe5d9/yarl-1.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96404e8d5e1bbe36bdaa84ef89dc36f0e75939e060ca5cd45451aba01db02902", size = 347257 }, - { url = "https://files.pythonhosted.org/packages/36/0b/33a093b0e13bb8cd0f27301779661ff325270b6644929001f8f33307357d/yarl-1.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a0509475d714df8f6d498935b3f307cd122c4ca76f7d426c7e1bb791bcd87eda", size = 359735 }, - { url = "https://files.pythonhosted.org/packages/a8/92/dcc0b37c48632e71ffc2b5f8b0509347a0bde55ab5862ff755dce9dd56c4/yarl-1.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ff116f0285b5c8b3b9a2680aeca29a858b3b9e0402fc79fd850b32c2bcb9f8b", size = 365982 }, - { url = "https://files.pythonhosted.org/packages/0e/39/30e2a24a7a6c628dccb13eb6c4a03db5f6cd1eb2c6cda56a61ddef764c11/yarl-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2580c1d7e66e6d29d6e11855e3b1c6381971e0edd9a5066e6c14d79bc8967af", size = 360128 }, - { url = "https://files.pythonhosted.org/packages/76/13/12b65dca23b1fb8ae44269a4d24048fd32ac90b445c985b0a46fdfa30cfe/yarl-1.18.0-cp313-cp313-win32.whl", hash = "sha256:14408cc4d34e202caba7b5ac9cc84700e3421a9e2d1b157d744d101b061a4a88", size = 309888 }, - { url = "https://files.pythonhosted.org/packages/f6/60/478d3d41a4bf0b9e7dca74d870d114e775d1ff7156b7d1e0e9972e8f97fd/yarl-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db1537e9cb846eb0ff206eac667f627794be8b71368c1ab3207ec7b6f8c5afc", size = 315459 }, - { url = "https://files.pythonhosted.org/packages/30/9c/3f7ab894a37b1520291247cbc9ea6756228d098dae5b37eec848d404a204/yarl-1.18.0-py3-none-any.whl", hash = "sha256:dbf53db46f7cf176ee01d8d98c39381440776fcda13779d269a8ba664f69bec0", size = 44840 }, +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ]